49 Commits
Author SHA1 Message Date
gurixandClaude Opus 4.6 175ff96404 chore: complete v1.1 milestone — Custom Sound Mappings
Archive roadmap and requirements to milestones/, update PROJECT.md
with shipped state, collapse ROADMAP.md, update retrospective.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:05:09 +01:00
gurixandClaude Opus 4.6 5391207b37 docs(phase-07): evolve PROJECT.md after phase completion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:56:40 +01:00
gurixandClaude Opus 4.6 072773c0df docs(phase-07): complete phase execution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:56:10 +01:00
gurix 3eebb0f590 docs(07-02): complete print-config and LoadResult wiring plan
- SUMMARY.md for plan 02: PrintConfig + CLI wiring
- STATE.md: updated progress (100%), metrics, decisions, session
- ROADMAP.md: Phase 7 marked Complete (2/2 summaries)
- REQUIREMENTS.md: CFG-06 marked complete
2026-03-26 21:52:44 +01:00
gurix b52e36b579 feat(07-02): implement PrintConfig function with comment annotations
- Add AutoClasses map[TrafficClass]bool to LoadResult for tracking auto-assigned classes
- PrintConfig() returns commented TOML with header (source, date), [[rules]] section, [sounds.*] section
- waveformString() helper converts WaveformType back to string
- classAnnotation() returns default/override/auto-assigned per class
- Built-in classes emitted in AllClasses() order; user-defined classes sorted alphabetically
- Rules section emits [[rules]] blocks; port omitted when DstPort==0
- Tests: TestPrintConfigContainsAllClasses, TestPrintConfigSourcePath, TestPrintConfigNoSourcePath,
  TestPrintConfigContainsRules, TestPrintConfigRuleNoPort, TestPrintConfigDefaultAnnotation,
  TestPrintConfigOverrideAnnotation, TestPrintConfigAutoAssignedAnnotation
2026-03-26 21:51:27 +01:00
gurix d43914f869 feat(07-02): wire LoadResult into main.go and add --print-config flag
- Add printConfig bool var and --print-config flag registration
- runPrintConfig() early-exit before interface-required check (CFG-06)
- runLiveMode and runPcapMode accept config.LoadResult; user rules prepend via append(result.UserRules, classify.DefaultRules...)
- Remove unused synth import from main.go
- Add TestPrintConfigFlagRegistered, TestPrintConfigNoInterface, TestPrintConfigWithConfigFile
- newTestCmd() wires --print-config and --config flags through PersistentPreRunE
2026-03-26 21:51:19 +01:00
gurix 9e71a805b8 docs(07-01): complete config rule parsing and LoadResult plan
- Add 07-01-SUMMARY.md
- Advance STATE.md to plan 2 of 2, progress 83%
- Update ROADMAP.md: phase 7 in progress (1/2 summaries)
- Mark RULE-01, RULE-02, RULE-03 complete in REQUIREMENTS.md
2026-03-26 21:46:13 +01:00
gurix 4b365cd5f4 feat(07-01): add RawRule, LoadResult, validation, auto-freq to config package
- Add RawRule struct with Port *uint16, Protocol, Class fields
- Add LoadResult struct with FreqCfgs, UserRules, ConfigPath fields
- Change Load() signature to return LoadResult instead of bare map
- Add validateRules: checks protocol required, class required, valid protocols
- Add convertRules: converts RawRule slices to classify.Rule slices
- Add autoAssignFreq: FNV-32a deterministic Hz in [1200-2350] range
- Add addAutoFreqEntries: creates FreqConfig for new class names, skips built-ins
- Reorder ops: addAutoFreqEntries before merge so sounds overrides apply to user classes
- Update main.go call site to use LoadResult.FreqCfgs
- Update all 8 existing tests to use LoadResult return type
- Add 13 new tests covering rule parsing, validation, auto-freq, and LoadResult
2026-03-26 21:44:53 +01:00
gurix 006e465b1e docs(07): create phase plan 2026-03-26 21:38:56 +01:00
gurixandClaude Opus 4.6 050d67d9f1 docs(phase-07): add research and validation strategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:34:27 +01:00
gurixandClaude Sonnet 4.6 e15972055d docs(07): research phase domain
Research for Phase 7 custom rules and print-config. Covers TOML array-of-tables parsing behavior, LoadResult struct design, FNV-32a auto-frequency algorithm, and print-config implementation patterns. All verified against existing source code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 21:33:11 +01:00
gurix 6cbfb66ef2 docs(state): record phase 7 context session 2026-03-26 21:27:34 +01:00
gurix fe372c8dc8 docs(07): capture phase context 2026-03-26 21:27:29 +01:00
gurixandClaude Opus 4.6 dddfb1b444 docs: update README and PROJECT.md to reflect Phase 6 completion
Add custom sound configuration section covering TOML config files,
auto-discovery, available waveforms, and validation behavior. Update
flags table, project structure, dependencies, and sound design table
with accurate frequencies. Add README update step to evolution checklist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:14:30 +01:00
gurix c3d78624ba docs(phase-06): evolve PROJECT.md after phase completion 2026-03-26 21:09:02 +01:00
gurix 6065bfcd90 docs(phase-06): complete phase execution 2026-03-26 21:08:49 +01:00
gurix e3487c5c94 docs(06-02): complete CLI config wiring plan summary and state updates 2026-03-26 21:05:50 +01:00
gurix 413cceb1eb feat(06-02): wire --config flag and config.Load into CLI pipeline
- Add configPath string var and --config flag to Cobra command
- Import config and synth packages in main.go
- Call config.Load(configPath) in run() before capture (D-11 fail fast)
- Change runLiveMode and runPcapMode signatures to accept freqCfgs map
- Pass freqCfgs to encode.RunSynthesis in both live and pcap modes
2026-03-26 21:04:11 +01:00
gurix 3dfcbbeaf5 feat(06-02): add freqCfgs parameter to RunSynthesis
- Change RunSynthesis signature to accept injected config map (D-10)
- Replace hardcoded synth.ClassFreqConfigs with passed-in freqCfgs
- Update all three RunSynthesis calls in encode tests to pass synth.ClassFreqConfigs
2026-03-26 21:02:35 +01:00
gurix 936aeeafd8 merge: resolve STATE.md conflict from wave 1 (keep executor version) 2026-03-26 21:00:15 +01:00
gurix 1986e8fc2e docs(06-01): complete config package plan summary and state updates
- 06-01-SUMMARY.md: TOML config loader with partial merge and unknown-key detection
- STATE.md: advanced to Plan 1 complete, added Phase 06-01 decisions and metrics
- ROADMAP.md: updated Phase 6 plan progress (1/2 plans complete)
- REQUIREMENTS.md: marked CFG-01, CFG-02, CFG-04, CFG-05 complete
2026-03-26 20:59:46 +01:00
gurix 1f877e7574 feat(06-01): implement config package with TOML load, merge, validate
- Load() discovers, parses, validates, and merges TOML config over defaults
- SoundOverride struct with *float64/*string pointer fields for partial-merge (CFG-04)
- parseFile uses BurntSushi/toml DecodeFile + Undecoded() for unknown-key errors (CFG-05)
- discoverPath probes ./netsynth.toml then ~/.config/netsynth/config.toml (CFG-02)
- merge applies non-nil overrides per class; warns on unknown class names (D-09)
- parseWaveform maps sine/square/sawtooth/triangle strings to WaveformType
- Harmonics regenerated via WaveformPresetHarmonics when waveform/frequency changes
- All 9 tests pass; go vet clean
2026-03-26 20:57:00 +01:00
gurix b9ec05aebc test(06-01): add failing tests for config package TOML load, merge, validate
- 9 table-driven tests covering CFG-01 through CFG-05
- TestLoadPartialOverrideFrequency, TestLoadPartialOverrideWaveform, TestLoadBothOverrides
- TestLoadUnknownKey, TestLoadNoConfig, TestLoadExplicitMissing
- TestLoadUnknownClass, TestLoadInvalidWaveform, TestLoadAllDefaultsPresent
- Stub config.Load returns nil,nil — all tests fail (RED)
2026-03-26 20:56:03 +01:00
gurix 0d9508e920 docs(06): create phase plan 2026-03-26 20:51:07 +01:00
gurix ece532b053 docs(phase-6): add validation strategy 2026-03-26 20:46:43 +01:00
gurix 21ebc5cdda docs(06): research phase domain 2026-03-26 20:45:56 +01:00
gurix 34f922cf95 docs(state): record phase 6 context session 2026-03-26 20:39:17 +01:00
gurix c1ab8e1777 docs(06): capture phase context 2026-03-26 20:39:08 +01:00
gurix 4ec9c64ee1 docs(phase-05): evolve PROJECT.md after phase completion 2026-03-26 17:42:52 +01:00
gurix d915c3cd11 docs(phase-05): complete phase execution 2026-03-26 17:42:35 +01:00
gurix d436ef154d Merge branch 'worktree-agent-a9867cbf' 2026-03-26 17:40:03 +01:00
gurix 8e357cf432 docs(05-02): complete bank-decoupling plan — SUMMARY, STATE, ROADMAP updated 2026-03-26 17:39:48 +01:00
gurix b2b5ab679b test(05-02): update tests for new NewBank signature and dynamic gain
- All NewBank calls updated to two-argument form passing ClassFreqConfigs
- TestMixerNoClip iterates ClassFreqConfigs keys instead of classify.AllClasses()
- TestNewBankDynamicGain verifies gainPerLayer=1/N for 3-class custom config
- TestNewBankCustomConfigNoClip verifies no-clip guarantee with 2-class config
- TestNumLayersMatchesAllClasses now asserts len(ClassFreqConfigs) == len(AllClasses())
2026-03-26 17:38:37 +01:00
gurix 43307c31d5 feat(05-02): decouple NewBank from global config and fix dynamic GainPerLayer
- NewBank now accepts (tau float64, cfgs map[classify.TrafficClass]FreqConfig)
- gainPerLayer field added to OscillatorBank, computed as 1.0/float64(len(cfgs))
- RenderWindow UpdateTarget loop iterates b.layers (not classify.AllClasses())
- RenderWindow render loop uses b.gainPerLayer (not GainPerLayer constant)
- encode/mp3.go updated to pass synth.ClassFreqConfigs as default config map
2026-03-26 17:37:44 +01:00
gurix a94ffdd8ef Merge branch 'worktree-agent-a46f9aac' 2026-03-26 17:36:22 +01:00
gurix 5e31da5442 docs(05-01): complete waveform types plan summary and state updates 2026-03-26 17:35:54 +01:00
gurix 7f6471426f feat(05-01): wire waveform resolution into NewLayer at construction time
- NewLayer resolves WaveformPresetHarmonics when cfg.WaveformType != WaveformCustom
- Preset harmonics stored in Layer.Config so AdvanceSample uses them unchanged
- WaveformCustom path preserves existing hand-tuned harmonics (backward compatible)
- TestNewLayerResolvesWaveformPreset: verifies preset fills harmonics on construction
- TestNewLayerPreservesCustomHarmonics: verifies hand-tuned harmonics are untouched
- TestSineRegressionVsCustomHarmonics: verifies WaveformSine == {Ratio:1,Amp:1.0}
- All 41 synth tests pass, encode tests unaffected
2026-03-26 17:34:45 +01:00
gurix 82d1e37d0f feat(05-01): add WaveformType enum and WaveformPresetHarmonics function
- WaveformType int with five constants: WaveformCustom (0), WaveformSine,
  WaveformSquare, WaveformSawtooth, WaveformTriangle
- WaveformPresetHarmonics generates bandlimited harmonic series for each type
- WaveformCustom returns nil to preserve existing hand-tuned harmonics
- All generated harmonics are below Nyquist (sampleRate/2)
- FreqConfig gains WaveformType field (zero value = WaveformCustom)
- ClassFreqConfigs converted to named fields (required for new struct field)
- All existing synth tests continue to pass (38 tests total)
2026-03-26 17:34:02 +01:00
gurix 88dee31264 test(05-01): add failing tests for WaveformType and WaveformPresetHarmonics
- TestWaveformPresetHarmonics_Sine: expects exactly {Ratio:1, Amplitude:1.0}
- TestWaveformPresetHarmonics_Square: expects odd harmonics with 1/k amplitude below Nyquist
- TestWaveformPresetHarmonics_Sawtooth: expects all harmonics with 1/k amplitude below Nyquist
- TestWaveformPresetHarmonics_Triangle: expects odd harmonics with alternating 1/k^2 amplitude
- TestWaveformPresetHarmonics_Custom: expects nil return
- TestBandlimitedHarmonicsNoAliasing: checks all ClassFreqConfigs entries at all waveform types
- TestWaveformPresetHarmonics_SquareOddOnly: odd ratios only
- TestWaveformPresetHarmonics_TriangleOddOnly: odd ratios only
- TestWaveformPresetHarmonics_SawtoothConsecutive: consecutive ratios starting at 1
2026-03-26 17:33:03 +01:00
gurix 716ffa82ee docs(05): create phase plan 2026-03-26 17:26:46 +01:00
gurix 9fe5d37f1e docs(phase-5): add validation strategy 2026-03-26 17:22:13 +01:00
gurix f0821f062d docs(05): research phase domain 2026-03-26 17:21:21 +01:00
gurix de43a132fb docs(state): record phase 5 context session 2026-03-26 17:11:17 +01:00
gurix 7ced25ae70 docs(05): capture phase context 2026-03-26 17:11:11 +01:00
gurix 8cb5120666 docs: create milestone v1.1 roadmap (3 phases) 2026-03-26 17:04:26 +01:00
gurix c5478d962d docs: define milestone v1.1 requirements 2026-03-26 17:00:58 +01:00
gurix d413d1243f docs: complete project research 2026-03-26 16:54:31 +01:00
gurix 8651c47b0f docs: start milestone v1.1 Custom Sound Mappings 2026-03-26 16:44:15 +01:00
gurixandClaude Opus 4.6 41e22788fc docs: add README with usage, architecture, and build instructions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:29:14 +01:00
55 changed files with 8497 additions and 978 deletions
+15
View File
@@ -1,5 +1,20 @@
# Milestones
## v1.1 Custom Sound Mappings (Shipped: 2026-03-26)
**Phases completed:** 3 phases, 6 plans, 3 tasks
**Key accomplishments:**
- Four waveform types (sine, square, sawtooth, triangle) with bandlimited additive synthesis and decoupled bank injection
- TOML config system with auto-discovery, partial override semantics, unknown-key validation, and `--config` flag
- User-defined classification rules via `[[rules]]` TOML blocks — prepend before built-ins, first-match-wins
- Auto-frequency assignment (FNV-32a hash, 1200-2350 Hz) for custom class names with no sound config
- `--print-config` flag outputs full effective config as commented TOML with source annotations
- End-to-end config flow: TOML file -> config.Load() -> LoadResult -> synthesis pipeline
---
## v1.0 MVP (Shipped: 2026-03-26)
**Phases completed:** 4 phases, 11 plans, 9 tasks
+58 -9
View File
@@ -2,7 +2,7 @@
## What This Is
A Go CLI tool that captures live network traffic on an interface, classifies packets by protocol, and synthesizes an ambient MP3 soundscape where each traffic type produces a distinct harmonic drone or tone. Supports live capture with BPF filtering and offline pcap file sonification.
A Go CLI tool that captures live network traffic on an interface, classifies packets by protocol, and synthesizes an ambient MP3 soundscape where each traffic type produces a distinct harmonic drone or tone. Supports live capture with BPF filtering, offline pcap file sonification, and fully customizable sound mappings via TOML config.
## Core Value
@@ -10,11 +10,11 @@ Network traffic patterns are instantly recognizable as distinct sounds — a pin
## Current State
**v1.0 MVP shipped 2026-03-26.** 3,254 lines of Go across 6 packages.
**v1.1 Custom Sound Mappings shipped 2026-03-26.** ~4,675 lines of Go across 7 packages.
Tech stack: gopacket/gopacket v1.5.0, packetcap/go-pcap (pure Go capture), sjzar/go-lame v0.0.9 (embedded LAME), spf13/cobra v1.10.2.
Tech stack: gopacket/gopacket v1.5.0, packetcap/go-pcap (pure Go capture), sjzar/go-lame v0.0.9 (embedded LAME), spf13/cobra v1.10.2, BurntSushi/toml v1.6.0.
All 16 v1 requirements validated. Full pipeline working: capture -> classify -> aggregate -> synthesize -> MP3.
All 16 v1.0 requirements + 11 v1.1 requirements validated. Full pipeline with customizable sound mappings: capture -> classify -> aggregate -> synthesize -> MP3.
## Requirements
@@ -32,16 +32,28 @@ All 16 v1 requirements validated. Full pipeline working: capture -> classify ->
- BPF capture filter for scoping live traffic
- Offline pcap file sonification with timestamp-based windowing
### Validated (v1.1)
- TOML config file with partial override semantics (frequency, waveform per class)
- Auto-discovery: `./netsynth.toml`, `~/.config/netsynth/config.toml`
- `--config` flag for explicit config path (error if missing)
- Unknown-key validation with clear error naming the typo'd key
- Four waveform types: sine, square, sawtooth, triangle (bandlimited)
- User-defined classification rules via `[[rules]]` TOML blocks
- User rules prepend before built-ins (first-match-wins priority)
- Auto-frequency assignment for custom class names (no silent gaps)
- `--print-config` outputs effective config as commented TOML
### Active
(None — next milestone requirements TBD)
(No active requirements — next milestone not yet defined)
### Out of Scope
- Real-time audio playback — v1 is file output only
- Real-time audio playback — file output only
- GUI or web interface — CLI only
- Custom sound mapping configuration — predefined + auto-cluster only
- Rhythmic/percussive output — ambient/drone style only
- Stereo position configuration — add in future if requested
## Context
@@ -49,7 +61,8 @@ All 16 v1 requirements validated. Full pipeline working: capture -> classify ->
- Packet capture requires root/CAP_NET_RAW on Linux
- Pure Go capture layer (no libpcap dependency)
- MP3 encoding embeds LAME C source (no system library needed)
- 14 traffic classes: 10 known protocols + 4 hash-bucketed unknowns
- 14 built-in traffic classes: 10 known protocols + 4 hash-bucketed unknowns (extensible via custom rules)
- TOML config with partial overrides, unknown-key validation, auto-discovery
## Constraints
@@ -72,10 +85,46 @@ All 16 v1 requirements validated. Full pipeline working: capture -> classify ->
| Hash-bucketed unknowns over k-means | Deterministic, zero-config, sufficient for v1 audio distinction | Good |
| Ordered []Rule classifier over switch | Configurable, extensible, first-match-wins semantics | Good |
| 500ms window duration | Balances temporal resolution against snapshot frequency for synthesis | Good |
| BurntSushi/toml over manual parsing | Industry-standard Go TOML library, Undecoded() catches typos | Good |
| Pointer fields for partial overrides | `*float64`, `*string` distinguish "not set" from zero values | Good |
| Bandlimited additive synthesis | Prevents aliasing in square/sawtooth/triangle without FFT overhead | Good |
| FNV-32a hash for auto-frequency | Deterministic, collision-resistant, maps to unused 1200-2350 Hz range | Good |
| LoadResult struct over tuple return | Clean single return value, extensible for future fields | Good |
| --print-config as flag (not subcommand) | Consistent with --list-interfaces pattern, simpler CLI surface | Good |
## Shipped Milestones
<details>
<summary>v1.1 Custom Sound Mappings (shipped 2026-03-26)</summary>
Users can customize how traffic sounds via a TOML config file — frequency, waveform, custom classification rules, and config inspection.
</details>
<details>
<summary>v1.0 MVP (shipped 2026-03-26)</summary>
Full capture -> classify -> synthesize -> MP3 pipeline with 14 traffic classes, BPF filtering, and pcap sonification.
</details>
## Evolution
This document evolves at phase transitions and milestone boundaries.
**After each phase transition:**
1. Requirements invalidated? -> Move to Out of Scope with reason
2. Requirements validated? -> Move to Validated with phase reference
3. New requirements emerged? -> Add to Active
4. Decisions to log? -> Add to Key Decisions
5. "What This Is" still accurate? -> Update if drifted
6. Update README.md to reflect the current state of the project (features, usage, installation)
**After each milestone:**
1. Full review of all sections
2. Core Value check — still the right priority?
3. Audit Out of Scope — reasons still valid?
4. Update Context with current state
---
*Last updated: 2026-03-26 after v1.0 milestone*
*Last updated: 2026-03-26 after v1.1 milestone*
+44 -7
View File
@@ -37,12 +37,49 @@
- go-audio/wav was unnecessary — writing PCM bytes directly to LameWriter is simpler
- Hash-bucketed unknowns (4 classes) are sufficient for audio distinction without k-means complexity
## Milestone: v1.1 — Custom Sound Mappings
**Shipped:** 2026-03-26
**Phases:** 3 | **Plans:** 6 | **Timeline:** 1 day (2026-03-26)
**LOC:** ~4,675 Go (+1,421 from v1.0) | **Packages:** 7
### What Was Built
- Four waveform types (sine, square, sawtooth, triangle) with bandlimited additive synthesis
- TOML config system: auto-discovery, partial overrides, unknown-key validation, `--config` flag
- User-defined `[[rules]]` classification rules with first-match-wins prepend semantics
- Auto-frequency assignment (FNV-32a hash) for custom class names
- `--print-config` flag with commented TOML output and source annotations
- LoadResult struct pattern for clean config-to-pipeline data flow
### What Worked
- Incremental config extension: Phase 6 built the config package, Phase 7 extended it cleanly
- TDD plans (type: tdd in frontmatter) produced higher-quality code with fewer regressions
- FNV-32a frequency assignment was verified experimentally during research before planning
- Worktree isolation for parallel executor agents prevented merge conflicts
- Reusing existing patterns (Rule struct, NewClassifier injection, Cobra flag-on-root) kept code consistent
### What Was Inefficient
- SUMMARY.md one-liner extraction continued to be noisy — summary-extract needs improvement
- Phase 5 could potentially have been merged with Phase 6 (waveform + config together)
### Patterns Established
- LoadResult struct for multi-value config returns (extensible without breaking callers)
- Pointer fields (`*float64`, `*string`) for partial TOML override semantics
- FNV-32a hash for deterministic resource assignment from string keys
- Flag-on-root pattern for early-exit operations (--list-interfaces, --print-config)
- `addAutoFreqEntries()` pattern: fill gaps in config before merge
### Key Lessons
- BurntSushi/toml Undecoded() works with array-of-tables (verified experimentally)
- Manual string building beats TOML encoder when you need inline comments/annotations
- Config extension is smooth when the original Load() was designed with clean boundaries
## Cross-Milestone Trends
| Metric | v1.0 |
|--------|------|
| Phases | 4 |
| Plans | 11 |
| Days | 3 |
| LOC | 3,254 |
| Avg plan duration | ~8 min |
| Metric | v1.0 | v1.1 |
|--------|------|------|
| Phases | 4 | 3 |
| Plans | 11 | 6 |
| Days | 3 | 1 |
| LOC | 3,254 | 4,675 |
| Avg plan duration | ~8 min | ~5 min |
+19 -4
View File
@@ -3,21 +3,33 @@
## Milestones
- **v1.0 MVP** — Phases 1-4 (shipped 2026-03-26)
- **v1.1 Custom Sound Mappings** — Phases 5-7 (shipped 2026-03-26)
## Phases
<details>
<summary>v1.0 MVP (Phases 1-4) — SHIPPED 2026-03-26</summary>
- [x] Phase 1: Capture and Classification (4/4 plans) — completed 2026-03-25
- [x] Phase 2: Audio Synthesis Engine (3/3 plans) — completed 2026-03-26
- [x] Phase 3: Pipeline Integration and MVP (2/2 plans) — completed 2026-03-26
- [x] Phase 4: Power User Features (2/2 plans) — completed 2026-03-26
- [x] **Phase 1: Capture and Classification** - 4/4 plans — completed 2026-03-25
- [x] **Phase 2: Audio Synthesis Engine** - 3/3 plans — completed 2026-03-26
- [x] **Phase 3: Pipeline Integration and MVP** - 2/2 plans — completed 2026-03-26
- [x] **Phase 4: Power User Features** - 2/2 plans — completed 2026-03-26
Full details: `.planning/milestones/v1.0-ROADMAP.md`
</details>
<details>
<summary>v1.1 Custom Sound Mappings (Phases 5-7) — SHIPPED 2026-03-26</summary>
- [x] **Phase 5: Waveform Types and Bank Decoupling** - 2/2 plans — completed 2026-03-26
- [x] **Phase 6: Config Package and Sound Overrides** - 2/2 plans — completed 2026-03-26
- [x] **Phase 7: Custom Rules and Print-Config** - 2/2 plans — completed 2026-03-26
Full details: `.planning/milestones/v1.1-ROADMAP.md`
</details>
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
@@ -26,3 +38,6 @@ Full details: `.planning/milestones/v1.0-ROADMAP.md`
| 2. Audio Synthesis Engine | v1.0 | 3/3 | Complete | 2026-03-26 |
| 3. Pipeline Integration and MVP | v1.0 | 2/2 | Complete | 2026-03-26 |
| 4. Power User Features | v1.0 | 2/2 | Complete | 2026-03-26 |
| 5. Waveform Types and Bank Decoupling | v1.1 | 2/2 | Complete | 2026-03-26 |
| 6. Config Package and Sound Overrides | v1.1 | 2/2 | Complete | 2026-03-26 |
| 7. Custom Rules and Print-Config | v1.1 | 2/2 | Complete | 2026-03-26 |
+50 -27
View File
@@ -1,15 +1,17 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: MVP
status: v1.0 milestone complete
stopped_at: Milestone v1.0 archived
last_updated: "2026-03-26T14:50:00.000Z"
milestone: v1.1
milestone_name: Custom Sound Mappings
status: milestone_complete
stopped_at: v1.1 milestone shipped
last_updated: "2026-03-26T21:02:36.710Z"
last_activity: 2026-03-26
progress:
total_phases: 4
completed_phases: 4
total_plans: 11
completed_plans: 11
total_phases: 3
completed_phases: 3
total_plans: 6
completed_plans: 6
percent: 0
---
# Project State
@@ -23,30 +25,51 @@ See: .planning/PROJECT.md (updated 2026-03-26)
## Current Position
Phase: All v1.0 phases complete
Phase: All v1.1 phases complete
Plan: N/A
Status: Milestone v1.1 shipped — ready for next milestone
Last activity: 2026-03-26
Progress: [░░░░░░░░░░] 0%
## Performance Metrics
| Phase | Duration | Tasks | Files |
|-------|----------|-------|-------|
| Phase 01 P01 | 4min | 2 tasks | 6 files |
| Phase 01 P02 | 3min | 1 tasks | 4 files |
| Phase 01 P03 | 8min | 2 tasks | 4 files |
| Phase 01 P04 | 15min | 2 tasks | 2 files |
| Phase 02 P01 | 15min | 2 tasks | 8 files |
| Phase 02 P02 | 10min | 2 tasks | 4 files |
| Phase 02 P03 | 3min | 2 tasks | 3 files |
| Phase 03 P01 | 15min | 2 tasks | 6 files |
| Phase 03 P02 | 5min | 1 tasks | 1 files |
| Phase 04 P01 | 3min | 2 tasks | 9 files |
| Phase 04 P02 | 4min | 1 tasks | 2 files |
**Velocity (v1.0 baseline):**
- Total plans completed: 11
- Average duration: 7.7 min
- Total execution time: ~1.4 hours
**By Phase (v1.0):**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| 01 | 4 | ~30min | 7.5min |
| 02 | 3 | ~28min | 9.3min |
| 03 | 2 | ~20min | 10min |
| 04 | 2 | ~7min | 3.5min |
**Recent Trend:** Stable
| Phase 05 P01 | 3min | 2 tasks | 3 files |
| Phase 05 P02 | 4 | 2 tasks | 4 files |
| Phase 06 P02 | 2 | 2 tasks | 3 files |
| Phase 07 P01 | 3 | 1 tasks | 3 files |
## Accumulated Context
### Decisions
All decisions archived in PROJECT.md Key Decisions table and `.planning/milestones/v1.0-ROADMAP.md`.
- [v1.1 Roadmap]: Phase 5 consolidates waveform types (WAVE-01, WAVE-02) with bank decoupling — both are internal refactors with no user-visible surface, establishing the injectable seam before config is added
- [v1.1 Roadmap]: Research steps 5+6 (wire config + freq/waveform overrides) collapsed into Phase 6 — they share the same integration boundary (encode.RunSynthesis signature change) and are safer to land together
- [v1.1 Roadmap]: User class name collision with built-in TrafficClass strings is an unresolved design question — decide before coding Phase 7 (treat as override vs. reject as ambiguous)
- [Phase 05]: FreqConfig struct uses named field syntax for ClassFreqConfigs entries (required by WaveformType addition)
- [Phase 05]: WaveformType zero value is WaveformCustom — all 14 existing ClassFreqConfigs entries retain hand-tuned harmonics without modification
- [Phase 05]: NewBank accepts injected config map instead of reading ClassFreqConfigs global — injection seam for Phase 6 config loading
- [Phase 05]: gainPerLayer computed as 1.0/float64(len(cfgs)) — correct for any class count, no-clip guarantee preserved
- [Phase 06]: Option A for freqCfgs propagation: pass as parameter to runLiveMode/runPcapMode — cleaner data flow vs package-level var
- [Phase 06]: config.Load positioned after BPF validation, before output path resolution — ensures fail-fast before any I/O (D-11)
- [Phase 07]: addAutoFreqEntries runs before merge so [sounds.X] overrides apply to user-defined classes
- [Phase 07]: LoadResult struct chosen over tuple return for config.Load() -- cleaner API contract for Plan 02 CLI wiring
### Pending Todos
@@ -54,10 +77,10 @@ None.
### Blockers/Concerns
None — all v1.0 blockers resolved.
- [Phase 7 pre-work]: User-defined class names that collide with built-in class strings (e.g., `class = "HTTPS"`) require an explicit design decision before Phase 7 coding begins. Research flags this as unresolved. Options: treat as override (simplest) or reject as ambiguous. Resolve during Phase 7 planning.
## Session Continuity
Last session: 2026-03-26T14:50:00.000Z
Stopped at: Milestone v1.0 archived
Last session: 2026-03-26T20:52:30.470Z
Stopped at: Completed 07-02-PLAN.md
Resume file: None
+1 -1
View File
@@ -26,7 +26,7 @@
"research_before_questions": false,
"discuss_mode": "discuss",
"skip_discuss": false,
"_auto_chain_active": false
"_auto_chain_active": true
},
"hooks": {
"context_warnings": true
+87
View File
@@ -0,0 +1,87 @@
# Requirements Archive: v1.1 Custom Sound Mappings
**Archived:** 2026-03-26
**Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`.
---
# Requirements: NetSynth
**Defined:** 2026-03-26
**Core Value:** Network traffic patterns are instantly recognizable as distinct sounds — a ping sounds different from HTTPS noise, which sounds different from a port scan.
## v1.1 Requirements
Requirements for custom sound mappings milestone. Each maps to roadmap phases.
### Config Loading
- [x] **CFG-01**: User can create a TOML config file that overrides default sound mappings
- [x] **CFG-02**: Tool auto-discovers config from `./netsynth.toml` or `~/.config/netsynth/config.toml` (silent if absent)
- [x] **CFG-03**: User can specify an explicit config path via `--config` flag (error if file missing)
- [x] **CFG-04**: User can override individual values without replicating the entire default config (partial override)
- [x] **CFG-05**: Unknown keys in config file produce a clear error with the typo'd key name
- [x] **CFG-06**: User can run `netsynth --print-config` to see the effective config as commented TOML
### Waveforms
- [x] **WAVE-01**: User can set waveform type per traffic class (sine, square, sawtooth, triangle)
- [x] **WAVE-02**: Non-sine waveforms use bandlimited additive synthesis (no aliasing artifacts)
### Custom Rules
- [x] **RULE-01**: User can define custom classification rules in TOML (match by port and/or protocol, assign class name and sound)
- [x] **RULE-02**: User-defined rules take priority over built-in rules (prepend before defaults)
- [x] **RULE-03**: User-defined class names automatically get a synthesis layer (no silent gaps)
## Future Requirements
Deferred to later releases.
### Audio Tuning
- **TUNE-01**: User can configure time window duration via `--window` flag
- **TUNE-02**: User can configure output duration when reading pcap files via `--duration` flag
### Distribution
- **DIST-01**: Single static binary with no runtime dependencies
## Out of Scope
| Feature | Reason |
|---------|--------|
| Stereo position configuration | Keeps v1.1 scope focused; add in future if requested |
| Real-time audio playback | File output only — established v1.0 constraint |
| GUI config editor | CLI-only tool; TOML is human-editable |
| JSON/YAML config format | TOML chosen for readability; one format keeps it simple |
| Config hot-reload | Non-interactive tool; config read once at startup |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| CFG-01 | Phase 6 | Complete |
| CFG-02 | Phase 6 | Complete |
| CFG-03 | Phase 6 | Complete |
| CFG-04 | Phase 6 | Complete |
| CFG-05 | Phase 6 | Complete |
| CFG-06 | Phase 7 | Complete |
| WAVE-01 | Phase 5 | Complete |
| WAVE-02 | Phase 5 | Complete |
| RULE-01 | Phase 7 | Complete |
| RULE-02 | Phase 7 | Complete |
| RULE-03 | Phase 7 | Complete |
**Coverage:**
- v1.1 requirements: 11 total
- Mapped to phases: 11
- Unmapped: 0
---
*Requirements defined: 2026-03-26*
*Last updated: 2026-03-26 after roadmap creation (traceability complete)*
+87
View File
@@ -0,0 +1,87 @@
# Roadmap: NetSynth
## Milestones
- **v1.0 MVP** — Phases 1-4 (shipped 2026-03-26)
- **v1.1 Custom Sound Mappings** — Phases 5-7 (in progress)
## Phases
<details>
<summary>v1.0 MVP (Phases 1-4) — SHIPPED 2026-03-26</summary>
- [x] **Phase 1: Capture and Classification** - 4/4 plans — completed 2026-03-25
- [x] **Phase 2: Audio Synthesis Engine** - 3/3 plans — completed 2026-03-26
- [x] **Phase 3: Pipeline Integration and MVP** - 2/2 plans — completed 2026-03-26
- [x] **Phase 4: Power User Features** - 2/2 plans — completed 2026-03-26
Full details: `.planning/milestones/v1.0-ROADMAP.md`
</details>
### v1.1 Custom Sound Mappings (In Progress)
**Milestone Goal:** Users can customize how traffic sounds via a TOML config file — setting custom frequencies, waveform types, and their own classification rules with named sounds.
- [x] **Phase 5: Waveform Types and Bank Decoupling** - Internal refactors establishing waveform enum and injectable bank signature (completed 2026-03-26)
- [x] **Phase 6: Config Package and Sound Overrides** - TOML loading, auto-discovery, partial merge, and frequency/waveform overrides wired end-to-end (completed 2026-03-26)
- [x] **Phase 7: Custom Rules and Print-Config** - User-defined classification rules and --print-config UX (completed 2026-03-26)
## Phase Details
### Phase 5: Waveform Types and Bank Decoupling
**Goal**: Four waveform types are available per traffic class, and the synthesis bank accepts an injected config map instead of reading global state
**Depends on**: Phase 4
**Requirements**: WAVE-01, WAVE-02
**Success Criteria** (what must be TRUE):
1. User can set a traffic class to square, sawtooth, or triangle waveform and hear a tonally distinct sound with no audible aliasing or buzzing artifacts
2. Sine waveform continues to produce the same output as v1.0 — no regression
3. The synthesis bank builds layers from a passed-in config map rather than a hardcoded class list
**Plans:** 2/2 plans complete
Plans:
- [x] 05-01-PLAN.md — Waveform types: WaveformType enum, WaveformPresetHarmonics, NewLayer resolution
- [x] 05-02-PLAN.md — Bank decoupling: NewBank injected config map, dynamic GainPerLayer, test updates
### Phase 6: Config Package and Sound Overrides
**Goal**: Users can create a TOML config file to override frequency and waveform per traffic class, with auto-discovery, partial override semantics, and clear validation errors
**Depends on**: Phase 5
**Requirements**: CFG-01, CFG-02, CFG-03, CFG-04, CFG-05
**Success Criteria** (what must be TRUE):
1. User creates a `netsynth.toml` in the working directory with a custom Hz value and the tool uses that frequency for the specified class without touching other classes
2. User runs the tool with no flags in a directory without a config file — it starts silently (no warning about missing config)
3. User passes `--config /path/to/custom.toml` and the tool uses that file; if the file does not exist, the tool exits with a clear error before capture begins
4. User types `frequncy = 440` in their config file and the tool exits at startup with an error naming `frequncy` as an unrecognized key
5. User sets waveform for one class in TOML and leaves all other classes at their defaults — the unspecified classes are unchanged
**Plans:** 2/2 plans complete
Plans:
- [x] 06-01-PLAN.md — Config package: TOML load, validate, merge with TDD (config/config.go, config/config_test.go)
- [x] 06-02-PLAN.md — CLI wiring: --config flag, RunSynthesis signature change, main.go integration
### Phase 7: Custom Rules and Print-Config
**Goal**: Users can define their own traffic classification rules in TOML, assign custom sounds to them, and inspect the full effective config before capture begins
**Depends on**: Phase 6
**Requirements**: RULE-01, RULE-02, RULE-03, CFG-06
**Success Criteria** (what must be TRUE):
1. User adds a `[[rules]]` block in TOML matching a custom port/protocol combination and hears a distinct tone for that traffic in the output MP3
2. User-defined rules fire before built-in protocol rules — a custom rule for port 443 overrides the default HTTPS classification for packets on that port
3. A user-defined class name gets its own synthesis layer automatically — no silence or missing audio for traffic matched by a custom rule
4. User runs `netsynth --print-config` and sees the full effective config (defaults merged with their overrides) as commented TOML, without starting a capture
**Plans:** 2/2 plans complete
Plans:
- [x] 07-01-PLAN.md — Config extension: RawRule, LoadResult, rule validation, auto-freq assignment (TDD)
- [x] 07-02-PLAN.md — CLI wiring: --print-config flag, user rule prepend, PrintConfig output
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Capture and Classification | v1.0 | 4/4 | Complete | 2026-03-25 |
| 2. Audio Synthesis Engine | v1.0 | 3/3 | Complete | 2026-03-26 |
| 3. Pipeline Integration and MVP | v1.0 | 2/2 | Complete | 2026-03-26 |
| 4. Power User Features | v1.0 | 2/2 | Complete | 2026-03-26 |
| 5. Waveform Types and Bank Decoupling | v1.1 | 2/2 | Complete | 2026-03-26 |
| 6. Config Package and Sound Overrides | v1.1 | 2/2 | Complete | 2026-03-26 |
| 7. Custom Rules and Print-Config | v1.1 | 2/2 | Complete | 2026-03-26 |
@@ -0,0 +1,216 @@
---
phase: 05-waveform-types-and-bank-decoupling
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- synth/config.go
- synth/layer.go
- synth/waveform_test.go
autonomous: true
requirements:
- WAVE-01
- WAVE-02
must_haves:
truths:
- "WaveformType enum exists with five values: WaveformCustom (0), WaveformSine, WaveformSquare, WaveformSawtooth, WaveformTriangle"
- "WaveformPresetHarmonics returns correct bandlimited harmonic series for each waveform type"
- "All generated partials are below Nyquist frequency (22050 Hz)"
- "WaveformCustom returns nil, preserving existing hand-tuned harmonics"
- "NewLayer resolves waveform presets at construction time, not at render time"
- "Existing tests still pass — no regression in v1.0 behavior"
artifacts:
- path: "synth/config.go"
provides: "WaveformType enum and WaveformPresetHarmonics function"
contains: "WaveformType"
exports: ["WaveformType", "WaveformCustom", "WaveformSine", "WaveformSquare", "WaveformSawtooth", "WaveformTriangle", "WaveformPresetHarmonics"]
- path: "synth/layer.go"
provides: "Waveform resolution in NewLayer"
contains: "WaveformPresetHarmonics"
- path: "synth/waveform_test.go"
provides: "Tests for waveform preset generation and bandlimiting"
key_links:
- from: "synth/layer.go"
to: "synth/config.go"
via: "NewLayer calls WaveformPresetHarmonics when cfg.WaveformType != WaveformCustom"
pattern: "WaveformPresetHarmonics\\(cfg\\.WaveformType"
---
<objective>
Add four waveform types (sine, square, sawtooth, triangle) to the synthesis engine using bandlimited additive synthesis.
Purpose: Enables per-traffic-class waveform selection (WAVE-01) with aliasing-free generation (WAVE-02). This is the foundation that Phase 6 config loading will expose to users.
Output: WaveformType enum, WaveformPresetHarmonics() function, NewLayer waveform resolution, and comprehensive 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/STATE.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md
@synth/config.go
@synth/layer.go
@synth/oscillator.go
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From synth/config.go:
```go
type HarmonicDef struct {
Ratio int
Amplitude float64
}
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
}
const SampleRate = 44100
```
From synth/oscillator.go:
```go
func (o *Oscillator) Advance(harmonics []HarmonicDef) float64
```
From synth/layer.go:
```go
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer
func (l *Layer) AdvanceSample() float64 // calls l.Osc.Advance(l.Config.Harmonics)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add WaveformType enum and WaveformPresetHarmonics function</name>
<files>synth/config.go, synth/waveform_test.go</files>
<read_first>synth/config.go, synth/oscillator.go, synth/layer.go</read_first>
<behavior>
- TestWaveformPresetHarmonics_Sine: WaveformPresetHarmonics(WaveformSine, 440.0, 44100) returns exactly []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
- TestWaveformPresetHarmonics_Square: WaveformPresetHarmonics(WaveformSquare, 440.0, 44100) returns odd harmonics (1,3,5,...) with amplitude 1/k, all below Nyquist
- TestWaveformPresetHarmonics_Sawtooth: WaveformPresetHarmonics(WaveformSawtooth, 440.0, 44100) returns all harmonics (1,2,3,...) with amplitude 1/k, all below Nyquist
- TestWaveformPresetHarmonics_Triangle: WaveformPresetHarmonics(WaveformTriangle, 440.0, 44100) returns odd harmonics with alternating sign and 1/k^2 amplitude, all below Nyquist
- TestWaveformPresetHarmonics_Custom: WaveformPresetHarmonics(WaveformCustom, 440.0, 44100) returns nil
- TestBandlimitedHarmonicsNoAliasing: For each non-custom waveform type, at every ClassFreqConfigs base frequency, no harmonic's Ratio*baseHz exceeds 22050
- TestWaveformPresetHarmonics_SquareOddOnly: All returned ratios for square are odd numbers
- TestWaveformPresetHarmonics_TriangleOddOnly: All returned ratios for triangle are odd numbers
- TestWaveformPresetHarmonics_SawtoothConsecutive: Returned ratios for sawtooth are consecutive integers starting at 1
</behavior>
<action>
Per D-01 and D-02, add to synth/config.go:
1. Define WaveformType as `type WaveformType int` with five constants:
```go
const (
WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is
WaveformSine
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
```
2. Add `WaveformType WaveformType` field to the `FreqConfig` struct (after Pan). Zero value is WaveformCustom, so all existing ClassFreqConfigs entries automatically use their hand-tuned harmonics (per D-03).
3. Add function `WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef`:
- WaveformCustom: return nil
- WaveformSine: return `[]HarmonicDef{{Ratio: 1, Amplitude: 1.0}}`
- WaveformSquare: loop `k := 1; float64(k)*baseHz < nyquist; k += 2` — append `HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)}`
- WaveformSawtooth: loop `k := 1; float64(k)*baseHz < nyquist; k++` — append `HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)}`
- WaveformTriangle: loop `k := 1; float64(k)*baseHz < nyquist; k += 2` with alternating sign — append `HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)}`, then `sign = -sign` (start `sign := 1.0`)
- Nyquist is `float64(sampleRate) / 2.0`
4. Do NOT modify ClassFreqConfigs entries — they retain their hand-tuned harmonics with the default WaveformCustom zero value (per D-03).
5. Create synth/waveform_test.go (package synth_test) with all tests from the behavior block. Use `synth.WaveformPresetHarmonics(...)` calls. The bandlimit test should iterate all ClassFreqConfigs entries, call WaveformPresetHarmonics for each of {WaveformSine, WaveformSquare, WaveformSawtooth, WaveformTriangle} with that entry's BaseHz, and assert `float64(h.Ratio) * baseHz < 22050.0` for every returned HarmonicDef.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestWaveformPreset|TestBandlimited" -v</automated>
</verify>
<acceptance_criteria>
- synth/config.go contains `type WaveformType int`
- synth/config.go contains `WaveformCustom WaveformType = iota`
- synth/config.go contains `WaveformSine`, `WaveformSquare`, `WaveformSawtooth`, `WaveformTriangle`
- synth/config.go contains `func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef`
- FreqConfig struct contains `WaveformType WaveformType`
- synth/waveform_test.go exists and contains `TestWaveformPresetHarmonics` and `TestBandlimitedHarmonicsNoAliasing`
- `go test ./synth/... -run "TestWaveformPreset|TestBandlimited"` exits 0
- `go test ./synth/...` exits 0 (no regression in existing tests)
</acceptance_criteria>
<done>WaveformType enum exported with 5 values, WaveformPresetHarmonics generates correct bandlimited series for all 4 waveform types, returns nil for WaveformCustom, all tests pass including existing suite</done>
</task>
<task type="auto">
<name>Task 2: Wire waveform resolution into NewLayer</name>
<files>synth/layer.go, synth/waveform_test.go</files>
<read_first>synth/layer.go, synth/config.go, synth/waveform_test.go</read_first>
<action>
Per D-02 and research Pattern 2, modify `NewLayer` in synth/layer.go to resolve waveform presets at construction time:
1. In `NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer`, add waveform resolution BEFORE creating the Layer. Insert at the top of the function:
```go
if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
}
```
This overwrites cfg.Harmonics (the local copy, not the original) with the bandlimited preset. The rest of NewLayer is unchanged — it stores cfg in `Layer.Config`, so `AdvanceSample` calls `l.Osc.Advance(l.Config.Harmonics)` with the resolved harmonics.
2. Add two tests to synth/waveform_test.go:
`TestNewLayerResolvesWaveformPreset`: Create a `synth.FreqConfig{BaseHz: 440.0, WaveformType: synth.WaveformSquare}` with empty Harmonics. Call `synth.NewLayer(cfg, synth.SampleRate, 1.0)`. Assert the returned layer's `Config.Harmonics` has length > 1 (preset was resolved). Verify the first harmonic has Ratio=1.
`TestNewLayerPreservesCustomHarmonics`: Create a `synth.FreqConfig{BaseHz: 440.0, Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.4}}}` with WaveformType left at zero (WaveformCustom). Call `synth.NewLayer(cfg, synth.SampleRate, 1.0)`. Assert harmonics length is exactly 2 and second harmonic Amplitude is 0.4.
`TestSineRegressionVsCustomHarmonics`: Create two layers — one with `WaveformType: synth.WaveformSine` and empty Harmonics, one with `WaveformType: synth.WaveformCustom` and `Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}`. Advance both 100 samples (calling layer.AdvanceSample on each). Assert samples are identical (both are pure sine at same frequency). Use a target amplitude of 1.0 by calling UpdateTarget(1, 1) first.
Note: The Layer struct fields Config, Osc are exported (capital first letter), so external tests (package synth_test) can access them. However AdvanceSample needs the layer to have a non-zero amplitude — call `layer.UpdateTarget(1, 1)` before advancing to set target to whisper+rate level, then advance enough samples for EMA to converge, OR use a very small tau like 0.001 for fast convergence in tests.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -v -count=1</automated>
</verify>
<acceptance_criteria>
- synth/layer.go NewLayer function contains `if cfg.WaveformType != WaveformCustom`
- synth/layer.go NewLayer function contains `WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)`
- synth/waveform_test.go contains `TestNewLayerResolvesWaveformPreset`
- synth/waveform_test.go contains `TestNewLayerPreservesCustomHarmonics`
- synth/waveform_test.go contains `TestSineRegressionVsCustomHarmonics`
- `go test ./synth/...` exits 0 (all existing tests still pass)
</acceptance_criteria>
<done>NewLayer resolves waveform presets at construction time. Custom harmonics are preserved when WaveformType is zero. Sine preset produces identical output to single-harmonic custom config. All tests pass.</done>
</task>
</tasks>
<verification>
- `go test ./synth/... -v` — all tests pass, including new waveform tests and all existing tests
- `go test ./encode/...` — encode package still compiles and passes (no changes to it in this plan)
- `go vet ./synth/...` — no warnings
</verification>
<success_criteria>
- WaveformType enum with 5 values is exported from synth package
- WaveformPresetHarmonics produces correct harmonic series for all 4 waveform types
- All generated harmonics are below Nyquist (no aliasing)
- WaveformCustom preserves existing hand-tuned harmonics
- NewLayer resolves presets at construction time (not render time)
- Sine waveform preset produces identical output to v1.0 single-harmonic custom
- All existing synth and encode tests pass without modification
</success_criteria>
<output>
After completion, create `.planning/phases/05-waveform-types-and-bank-decoupling/05-01-SUMMARY.md`
</output>
@@ -0,0 +1,78 @@
---
phase: 05-waveform-types-and-bank-decoupling
plan: "01"
subsystem: synth
tags: [waveform, additive-synthesis, bandlimiting, enum, tdd]
dependency_graph:
requires: []
provides: [WaveformType enum, WaveformPresetHarmonics, NewLayer waveform resolution]
affects: [synth/config.go, synth/layer.go]
tech_stack:
added: []
patterns: [TDD red-green, bandlimited additive synthesis, zero-value backward compat]
key_files:
created:
- synth/waveform_test.go
modified:
- synth/config.go
- synth/layer.go
decisions:
- "ClassFreqConfigs converted from positional to named struct literals (required by new WaveformType field)"
- "FreqConfig.WaveformType zero value is WaveformCustom, ensuring all existing entries auto-preserve hand-tuned harmonics"
metrics:
duration: "~3 min"
completed_date: "2026-03-26"
tasks: 2
files: 3
requirements:
- WAVE-01
- WAVE-02
---
# Phase 5 Plan 01: Waveform Types and WaveformPresetHarmonics Summary
WaveformType enum with four bandlimited presets (sine, square, sawtooth, triangle) added to synth package with construction-time resolution in NewLayer.
## What Was Built
- **`WaveformType int` enum** in `synth/config.go` with five constants: `WaveformCustom` (0), `WaveformSine`, `WaveformSquare`, `WaveformSawtooth`, `WaveformTriangle`
- **`WaveformPresetHarmonics(wt, baseHz, sampleRate)`** function that generates bandlimited harmonic series — all partials below Nyquist (sampleRate/2)
- **`FreqConfig.WaveformType` field** added; zero value `WaveformCustom` ensures full backward compatibility with all 14 existing `ClassFreqConfigs` entries
- **`NewLayer` waveform resolution** — presets resolved at construction time, stored in `Layer.Config.Harmonics`, so `AdvanceSample` requires no changes
- **`synth/waveform_test.go`** with 12 tests covering all preset shapes, bandlimit enforcement, odd-only ratios, consecutive ratios, nil return for Custom, regression vs hand-tuned harmonics, and NewLayer construction behavior
## Tasks Completed
| Task | Description | Commit | Files |
|------|-------------|--------|-------|
| 1 (RED) | Failing waveform tests | 88dee31 | synth/waveform_test.go |
| 1 (GREEN) | WaveformType enum + WaveformPresetHarmonics | 82d1e37 | synth/config.go |
| 2 | Wire waveform resolution into NewLayer + 3 more tests | 7f64714 | synth/layer.go, synth/waveform_test.go |
## Verification
- `go test ./synth/... -v`: 41 tests, all pass (28 existing + 12 new waveform + 1 regression)
- `go test ./encode/...`: 3 tests pass (no regressions)
- `go vet ./synth/...`: clean
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] ClassFreqConfigs positional struct literals broken by new field**
- **Found during:** Task 1 GREEN phase
- **Issue:** Adding `WaveformType WaveformType` field to `FreqConfig` caused compile errors on all 14 positional struct literals in `ClassFreqConfigs` ("too few values in struct literal")
- **Fix:** Converted all 14 entries from positional `{65.0, []HarmonicDef{...}, 0.0}` syntax to named field `{BaseHz: 65.0, Harmonics: []HarmonicDef{...}, Pan: 0.0}` syntax. WaveformType field implicitly zero (WaveformCustom), preserving hand-tuned harmonics as per D-03.
- **Files modified:** synth/config.go (ClassFreqConfigs block)
- **Commit:** 82d1e37
## Known Stubs
None — all waveform preset logic is fully implemented and wired.
## Self-Check: PASSED
- synth/waveform_test.go: FOUND
- synth/config.go (WaveformType): FOUND (verified by go test passing)
- synth/layer.go (NewLayer resolution): FOUND (verified by TestNewLayerResolvesWaveformPreset)
- Commits 88dee31, 82d1e37, 7f64714: all present in git log
@@ -0,0 +1,350 @@
---
phase: 05-waveform-types-and-bank-decoupling
plan: 02
type: execute
wave: 2
depends_on:
- "05-01"
files_modified:
- synth/bank.go
- synth/bank_test.go
- synth/config_test.go
- encode/mp3.go
autonomous: true
requirements:
- WAVE-01
- WAVE-02
must_haves:
truths:
- "NewBank accepts a config map parameter instead of reading the ClassFreqConfigs global"
- "GainPerLayer is computed dynamically as 1.0/len(configs) inside NewBank"
- "RenderWindow iterates b.layers instead of classify.AllClasses() in both loops"
- "encode.RunSynthesis passes synth.ClassFreqConfigs as the default config map"
- "All 14 built-in classes still produce the same audio output as v1.0"
- "No-clip guarantee holds with dynamic gain scaling"
artifacts:
- path: "synth/bank.go"
provides: "Decoupled OscillatorBank with injected config map"
contains: "gainPerLayer"
exports: ["NewBank", "OscillatorBank", "RenderWindow"]
- path: "encode/mp3.go"
provides: "Updated NewBank call site"
contains: "synth.ClassFreqConfigs"
- path: "synth/bank_test.go"
provides: "Updated tests for new NewBank signature"
- path: "synth/config_test.go"
provides: "Updated TestNumLayersMatchesAllClasses"
key_links:
- from: "encode/mp3.go"
to: "synth/bank.go"
via: "synth.NewBank(1.0, synth.ClassFreqConfigs)"
pattern: "NewBank\\(1\\.0,\\s*synth\\.ClassFreqConfigs\\)"
- from: "synth/bank.go"
to: "synth/layer.go"
via: "NewLayer(cfg, SampleRate, tau) for each config map entry"
pattern: "NewLayer\\(cfg,\\s*SampleRate"
- from: "synth/bank.go"
to: "synth/config.go"
via: "gainPerLayer computed from len(cfgs)"
pattern: "1\\.0\\s*/\\s*float64\\(len\\("
---
<objective>
Decouple OscillatorBank from the global ClassFreqConfigs variable and fix GainPerLayer to be dynamic.
Purpose: Creates the injection seam for Phase 6 config loading (D-05) and fixes gain scaling for variable class counts (D-04). After this plan, NewBank accepts any config map — not just the hardcoded 14 built-in classes.
Output: Updated bank.go with new NewBank signature, updated encode/mp3.go call site, updated 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/STATE.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-01-SUMMARY.md
@synth/bank.go
@synth/bank_test.go
@synth/config_test.go
@encode/mp3.go
<interfaces>
<!-- Key types and contracts from Plan 01 output -->
From synth/config.go (after Plan 01):
```go
type WaveformType int
const (
WaveformCustom WaveformType = iota
WaveformSine
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries, all WaveformCustom
```
From synth/layer.go (after Plan 01):
```go
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer
// Now resolves WaveformPresetHarmonics at construction if cfg.WaveformType != WaveformCustom
```
From classify package:
```go
type TrafficClass string
type WindowSnapshot struct {
Counts map[TrafficClass]int64
TotalPackets int64
WindowIndex int
}
func AllClasses() []TrafficClass
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Decouple NewBank and fix GainPerLayer</name>
<files>synth/bank.go, encode/mp3.go</files>
<read_first>synth/bank.go, synth/config.go, encode/mp3.go, synth/layer.go</read_first>
<action>
Per D-04 and D-05, refactor bank.go and update the single caller in encode/mp3.go:
1. In synth/bank.go, add `gainPerLayer float64` field to `OscillatorBank` struct:
```go
type OscillatorBank struct {
layers map[classify.TrafficClass]*Layer
tau float64
gainPerLayer float64
}
```
2. Change `NewBank` signature from `NewBank(tau float64)` to `NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig)`:
```go
func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank {
b := &OscillatorBank{
layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
tau: tau,
gainPerLayer: 1.0 / float64(len(cfgs)),
}
for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau)
}
return b
}
```
Key changes: iterate `cfgs` (not `classify.AllClasses()`), compute `gainPerLayer` dynamically from `len(cfgs)` (per D-04).
3. Update `RenderWindow` method — change BOTH loops from `classify.AllClasses()` to `b.layers`:
Loop 1 (UpdateTarget): Change from:
```go
for _, class := range classify.AllClasses() {
count := snap.Counts[class]
b.layers[class].UpdateTarget(count, maxCount)
}
```
To:
```go
for class, layer := range b.layers {
count := snap.Counts[class]
layer.UpdateTarget(count, maxCount)
}
```
Loop 2 (Render): Change from:
```go
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
}
```
To:
```go
for _, layer := range b.layers {
sample := layer.AdvanceSample()
gainL, gainR := PanGains(layer.Config.Pan)
sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR
}
```
Note: use `b.gainPerLayer` (the instance field) NOT the package constant `GainPerLayer`.
4. Update the `RenderWindow` doc comment to remove "Per D-10: each layer gets GainPerLayer (1/11)" — replace with "Each layer gets 1/N of the total gain where N is the number of layers."
5. Remove the `classify` import from bank.go ONLY IF it is no longer used. After the changes, `classify.TrafficClass` is still used in the `cfgs` parameter type and `b.layers` map type, and `classify.WindowSnapshot` is used in `RenderWindow`. So the import stays. However, `classify.AllClasses()` is no longer called — verify it is not referenced anywhere in bank.go.
6. In encode/mp3.go, change the single `NewBank` call from:
```go
bank := synth.NewBank(1.0)
```
To:
```go
bank := synth.NewBank(1.0, synth.ClassFreqConfigs)
```
This preserves v1.0 behavior exactly.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go build ./... && go vet ./synth/... ./encode/...</automated>
</verify>
<acceptance_criteria>
- synth/bank.go OscillatorBank struct contains `gainPerLayer float64`
- synth/bank.go NewBank signature is `func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank`
- synth/bank.go NewBank contains `gainPerLayer: 1.0 / float64(len(cfgs))`
- synth/bank.go NewBank iterates `for class, cfg := range cfgs` (NOT classify.AllClasses())
- synth/bank.go RenderWindow UpdateTarget loop uses `for class, layer := range b.layers`
- synth/bank.go RenderWindow render loop uses `for _, layer := range b.layers`
- synth/bank.go RenderWindow render loop uses `b.gainPerLayer` (NOT the GainPerLayer constant)
- synth/bank.go does NOT contain `classify.AllClasses()`
- encode/mp3.go contains `synth.NewBank(1.0, synth.ClassFreqConfigs)`
- `go build ./...` exits 0
</acceptance_criteria>
<done>NewBank accepts injected config map. GainPerLayer is dynamic. RenderWindow iterates b.layers in both loops. encode/mp3.go passes ClassFreqConfigs as default. Project compiles.</done>
</task>
<task type="auto">
<name>Task 2: Update tests for new NewBank signature and dynamic gain</name>
<files>synth/bank_test.go, synth/config_test.go</files>
<read_first>synth/bank_test.go, synth/config_test.go, synth/bank.go, synth/config.go</read_first>
<action>
Per Pitfall 4 from research, update all tests that call NewBank or reference NumLayers:
1. In synth/bank_test.go, update ALL `NewBank(...)` calls to pass `ClassFreqConfigs`:
- `TestNewBankHas14Layers`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`. Keep the assertion `len(b.layers) != 14` and the loop verifying each class has a layer. (This test uses internal package access since it's `package synth`.)
- `TestRenderWindowOutputLength`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
- `TestRenderWindowSilentWhenNoTraffic`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
- `TestRenderWindowNonZeroWithTraffic`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
- `TestMixerNoClip`: Change `NewBank(0.01)` to `NewBank(0.01, ClassFreqConfigs)`. Also change `classify.AllClasses()` in the count setup loop to iterate `ClassFreqConfigs` keys instead:
```go
for class := range ClassFreqConfigs {
counts[class] = 1000
}
```
And update TotalPackets to `int64(len(ClassFreqConfigs)) * 1000`.
- `TestStereoPan`: Change `NewBank(0.01)` to `NewBank(0.01, ClassFreqConfigs)`.
- `TestMultipleWindowsEMAConvergence`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
2. Add a new test `TestNewBankDynamicGain` to synth/bank_test.go:
```go
func TestNewBankDynamicGain(t *testing.T) {
// Create a config map with only 3 classes
cfgs := map[classify.TrafficClass]FreqConfig{
classify.ClassICMP: ClassFreqConfigs[classify.ClassICMP],
classify.ClassDNS: ClassFreqConfigs[classify.ClassDNS],
classify.ClassHTTPS: ClassFreqConfigs[classify.ClassHTTPS],
}
b := NewBank(0.01, cfgs)
if len(b.layers) != 3 {
t.Errorf("NewBank with 3 configs has %d layers, want 3", len(b.layers))
}
// Verify gainPerLayer is 1/3
expected := 1.0 / 3.0
if b.gainPerLayer != expected {
t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected)
}
}
```
3. Add a test `TestNewBankCustomConfigNoClip` to synth/bank_test.go to verify no-clip with a non-14 config:
```go
func TestNewBankCustomConfigNoClip(t *testing.T) {
cfgs := map[classify.TrafficClass]FreqConfig{
classify.ClassICMP: ClassFreqConfigs[classify.ClassICMP],
classify.ClassDNS: ClassFreqConfigs[classify.ClassDNS],
}
b := NewBank(0.01, cfgs)
counts := map[classify.TrafficClass]int64{
classify.ClassICMP: 1000,
classify.ClassDNS: 1000,
}
snap := classify.WindowSnapshot{Counts: counts, TotalPackets: 2000, WindowIndex: 0}
for i := 0; i < 10; i++ {
for _, frame := range b.RenderWindow(snap) {
if frame[0] > 1.0 || frame[0] < -1.0 || frame[1] > 1.0 || frame[1] < -1.0 {
t.Fatalf("clipped with 2-class config: L=%v R=%v", frame[0], frame[1])
}
}
}
}
```
4. In synth/config_test.go, update `TestNumLayersMatchesAllClasses`:
Change from asserting `synth.NumLayers != len(classify.AllClasses())` to asserting `len(synth.ClassFreqConfigs) == len(classify.AllClasses())`:
```go
func TestNumLayersMatchesAllClasses(t *testing.T) {
if len(synth.ClassFreqConfigs) != len(classify.AllClasses()) {
t.Errorf("ClassFreqConfigs has %d entries but AllClasses() has %d entries",
len(synth.ClassFreqConfigs), len(classify.AllClasses()))
}
}
```
This preserves the invariant that every built-in class has a config entry, without depending on the NumLayers constant.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... ./encode/... -v -count=1</automated>
</verify>
<acceptance_criteria>
- synth/bank_test.go contains no calls to `NewBank(1.0)` or `NewBank(0.01)` — all calls have two arguments
- synth/bank_test.go contains `TestNewBankDynamicGain` with assertion `b.gainPerLayer != expected`
- synth/bank_test.go contains `TestNewBankCustomConfigNoClip`
- synth/bank_test.go TestMixerNoClip iterates `ClassFreqConfigs` keys (not `classify.AllClasses()`)
- synth/config_test.go TestNumLayersMatchesAllClasses asserts `len(synth.ClassFreqConfigs) == len(classify.AllClasses())`
- synth/config_test.go TestNumLayersMatchesAllClasses does NOT reference `synth.NumLayers`
- `go test ./synth/... ./encode/...` exits 0
- `go test ./...` exits 0
</acceptance_criteria>
<done>All tests updated to new NewBank two-argument signature. Dynamic gain verified with custom config maps. No-clip test passes with non-14 class counts. TestNumLayersMatchesAllClasses updated. Full test suite green.</done>
</task>
</tasks>
<verification>
- `go test ./... -v` — full suite passes with no failures
- `go vet ./...` — no warnings
- `go build ./...` — compiles cleanly
- grep confirms no remaining `classify.AllClasses()` in bank.go
- grep confirms no remaining single-arg `NewBank(` calls in production or test code
</verification>
<success_criteria>
- NewBank accepts (tau, cfgs) — no global state dependency
- GainPerLayer computed as 1.0/len(cfgs) — correct for any class count
- RenderWindow iterates b.layers in both loops — no classify.AllClasses() calls
- encode.RunSynthesis passes ClassFreqConfigs — v1.0 behavior preserved
- No-clip guarantee holds for 2-class, 3-class, and 14-class configs
- Full test suite green (synth + encode + all other packages)
</success_criteria>
<output>
After completion, create `.planning/phases/05-waveform-types-and-bank-decoupling/05-02-SUMMARY.md`
</output>
@@ -0,0 +1,80 @@
---
phase: 05-waveform-types-and-bank-decoupling
plan: "02"
subsystem: synth
tags: [bank, decoupling, dynamic-gain, injection-seam, refactor]
dependency_graph:
requires: [05-01]
provides: [NewBank injected config map, gainPerLayer dynamic computation]
affects: [synth/bank.go, encode/mp3.go, synth/bank_test.go, synth/config_test.go]
tech_stack:
added: []
patterns: [dependency injection, dynamic gain scaling, config map injection]
key_files:
created: []
modified:
- synth/bank.go
- encode/mp3.go
- synth/bank_test.go
- synth/config_test.go
decisions:
- "NewBank now accepts (tau float64, cfgs map[classify.TrafficClass]FreqConfig) — no global state dependency"
- "gainPerLayer computed as 1.0/float64(len(cfgs)) so any N-class config auto-scales to avoid clipping"
- "RenderWindow iterates b.layers directly in both loops — no classify.AllClasses() dependency"
- "encode/mp3.go passes synth.ClassFreqConfigs as default — v1.0 behavior preserved exactly"
metrics:
duration: "~4 min"
completed_date: "2026-03-26"
tasks: 2
files: 4
requirements:
- WAVE-01
- WAVE-02
---
# Phase 5 Plan 02: Bank Decoupling and Dynamic GainPerLayer Summary
OscillatorBank decoupled from global ClassFreqConfigs via injected config map, with gainPerLayer computed dynamically as 1/N so any class count produces correct no-clip mixing.
## What Was Built
- **`OscillatorBank.gainPerLayer float64`** field added to struct — computed at construction time as `1.0 / float64(len(cfgs))`
- **`NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig)`** — new two-argument signature replaces global ClassFreqConfigs dependency; iterates cfgs map directly to create layers
- **`RenderWindow` UpdateTarget loop** — refactored from `classify.AllClasses()` iteration to `for class, layer := range b.layers`, making it work for any config map
- **`RenderWindow` render loop** — refactored to use `b.gainPerLayer` (instance field) instead of `GainPerLayer` constant, enabling correct scaling for non-14 class counts
- **`encode/mp3.go` call site** — updated to `synth.NewBank(1.0, synth.ClassFreqConfigs)`, preserving v1.0 behavior exactly
- **Updated test suite** — all 7 existing `NewBank` calls updated to two-argument form; two new tests added: `TestNewBankDynamicGain` (verifies 1/3 gain for 3-class config) and `TestNewBankCustomConfigNoClip` (verifies no-clip with 2-class config)
- **`TestNumLayersMatchesAllClasses`** updated to assert `len(synth.ClassFreqConfigs) == len(classify.AllClasses())` without depending on `synth.NumLayers`
## Tasks Completed
| Task | Description | Commit | Files |
|------|-------------|--------|-------|
| 1 | Decouple NewBank and fix GainPerLayer | 43307c3 | synth/bank.go, encode/mp3.go |
| 2 | Update tests for new NewBank signature and dynamic gain | b2b5ab6 | synth/bank_test.go, synth/config_test.go |
## Verification
- `go test ./synth/... ./encode/... -v`: 44 tests, all pass (41 existing + 2 new bank tests)
- `go test ./...`: all 6 packages pass (aggregate, capture, classify, cmd, encode, synth)
- `go vet ./...`: clean
- `go build ./...`: clean
- `classify.AllClasses()` not referenced in bank.go (confirmed via grep)
- No single-argument `NewBank(` calls remain in production or test code
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None — all decoupling logic is fully implemented and wired.
## Self-Check: PASSED
- synth/bank.go: FOUND (verified by go build)
- encode/mp3.go NewBank call updated: FOUND (synth.NewBank(1.0, synth.ClassFreqConfigs))
- synth/bank_test.go TestNewBankDynamicGain: FOUND (verified by go test)
- synth/bank_test.go TestNewBankCustomConfigNoClip: FOUND (verified by go test)
- synth/config_test.go TestNumLayersMatchesAllClasses updated: FOUND
- Commits 43307c3, b2b5ab6: both present in git log
@@ -0,0 +1,86 @@
# Phase 5: Waveform Types and Bank Decoupling - Context
**Gathered:** 2026-03-26
**Status:** Ready for planning
<domain>
## Phase Boundary
Extend the synthesis oscillator to support four waveform types (sine, square, sawtooth, triangle) using bandlimited additive synthesis, and decouple the OscillatorBank from the hardcoded `ClassFreqConfigs` global and `classify.AllClasses()` iteration — making it accept an injected config map instead.
</domain>
<decisions>
## Implementation Decisions
### Waveform Presets
- **D-01:** Use bandlimited additive synthesis with 8-12 partials per waveform type. Square wave uses odd harmonics (1,3,5,...,11), sawtooth uses all harmonics (1-12), triangle uses odd harmonics with 1/n^2 amplitude rolloff. This is the standard approach for aliasing-free waveform generation.
- **D-02:** Add a `WaveformType` enum to `FreqConfig` (`Sine`, `Square`, `Sawtooth`, `Triangle`). When waveform is set, generate the `[]HarmonicDef` from the preset formula. When waveform is unset/custom, use the existing hand-tuned `Harmonics` array.
### Built-in Harmonics Migration
- **D-03:** (Claude's Discretion) Decide whether built-in classes keep their hand-tuned HarmonicDef arrays or migrate to waveform presets. Recommended approach: keep existing harmonics as-is for v1.0 classes (preserves sound character), default them to `WaveformType = ""` (custom). Waveform presets only take effect when explicitly set via config in Phase 6.
### GainPerLayer Scaling
- **D-04:** Fix GainPerLayer now in Phase 5 — compute dynamically as `1.0 / float64(len(layers))` inside `NewBank` instead of using the hardcoded `NumLayers=14` constant. This establishes the correct foundation before Phase 7 adds dynamic class counts.
### Bank Decoupling
- **D-05:** (Claude's Discretion) Change `NewBank` to accept a `map[classify.TrafficClass]FreqConfig` parameter instead of reading the `ClassFreqConfigs` global. This is the injection seam that Phase 6 will use to pass merged config. The existing `ClassFreqConfigs` var remains as the default map.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Synthesis Architecture
- `synth/oscillator.go` — Current sine-only Oscillator with phase accumulator and `Advance([]HarmonicDef)`
- `synth/config.go``FreqConfig`, `HarmonicDef`, `ClassFreqConfigs` global, constants (`SampleRate`, `NumLayers`, `GainPerLayer`)
- `synth/bank.go``NewBank(tau)` iterates `classify.AllClasses()` and reads `ClassFreqConfigs` global
- `synth/layer.go``Layer` with EMA smoothing, uses `FreqConfig` from config.go
### Research
- `.planning/research/ARCHITECTURE.md` — Integration points and build order for v1.1
- `.planning/research/PITFALLS.md` — Pitfall A3 (aliasing) and A6 (bank class mismatch)
No external specs — requirements fully captured in decisions above.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `Oscillator.Advance([]HarmonicDef)` — Already supports additive synthesis via harmonic series. Waveform presets just need different `[]HarmonicDef` arrays, not a new oscillator type.
- `FreqConfig` struct — Has `BaseHz`, `Harmonics`, `Pan`. Adding `WaveformType` field is backward-compatible.
### Established Patterns
- Phase accumulator in `Oscillator` wraps at 1.0 — all harmonic ratios are integer multiples of the fundamental.
- `Layer` delegates to `Oscillator.Advance()` — waveform change is transparent to the layer.
- `ClassFreqConfigs` is a package-level `var` (not `const`) — can be replaced by parameter injection without breaking existing tests.
### Integration Points
- `NewBank(tau)``NewBank(tau, configs map[TrafficClass]FreqConfig)` — single signature change
- `bank.RenderWindow()` iterates `classify.AllClasses()` — must iterate `b.layers` map keys instead
- `encode.RunSynthesis` calls `NewBank(1.0)` — will need to pass config map (Phase 6 concern, but seam established here)
</code_context>
<specifics>
## Specific Ideas
No specific requirements — standard bandlimited synthesis approach with 8-12 partials as user requested.
</specifics>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope.
</deferred>
---
*Phase: 05-waveform-types-and-bank-decoupling*
*Context gathered: 2026-03-26*
@@ -0,0 +1,58 @@
# Phase 5: Waveform Types and Bank Decoupling - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-03-26
**Phase:** 05-waveform-types-and-bank-decoupling
**Areas discussed:** Waveform presets, GainPerLayer scaling
---
## Waveform presets
### Harmonic richness
| Option | Description | Selected |
|--------|-------------|----------|
| Bandlimited (8-12 partials) | Accurate waveform shapes, no aliasing. Standard for quality synthesis. | ✓ |
| Lightweight (4-6 partials) | Recognizably different but softer/rounder. Less CPU. | |
| You decide | Claude picks based on Nyquist and ambient use case | |
**User's choice:** Bandlimited (8-12 partials)
**Notes:** None
### Built-in class harmonics
| Option | Description | Selected |
|--------|-------------|----------|
| Keep current harmonics | Built-in classes retain hand-tuned arrays. Waveform presets only via config. | |
| Migrate to sine preset | Switch to pure fundamental. Simpler but loses v1.0 character. | |
| You decide | Claude picks best approach for preserving v1.0 sound | ✓ |
**User's choice:** You decide (Claude's Discretion)
**Notes:** None
---
## GainPerLayer scaling
| Option | Description | Selected |
|--------|-------------|----------|
| Fix now in Phase 5 | Compute dynamically as 1/len(layers). Clean foundation for Phase 7. | ✓ |
| Defer to Phase 7 | Keep NumLayers=14 constant. Fix when user classes land. | |
| You decide | Claude picks timing based on complexity | |
**User's choice:** Fix now in Phase 5
**Notes:** None
---
## Claude's Discretion
- Built-in class harmonics migration strategy (D-03)
- Bank config injection API design (D-05)
## Deferred Ideas
None
@@ -0,0 +1,439 @@
# Phase 5: Waveform Types and Bank Decoupling - Research
**Researched:** 2026-03-26
**Domain:** Go additive synthesis, oscillator architecture, dependency injection
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Use bandlimited additive synthesis with 8-12 partials per waveform type. Square wave uses odd harmonics (1,3,5,...,11), sawtooth uses all harmonics (1-12), triangle uses odd harmonics with 1/n^2 amplitude rolloff. This is the standard approach for aliasing-free waveform generation.
- **D-02:** Add a `WaveformType` enum to `FreqConfig` (`Sine`, `Square`, `Sawtooth`, `Triangle`). When waveform is set, generate the `[]HarmonicDef` from the preset formula. When waveform is unset/custom, use the existing hand-tuned `Harmonics` array.
- **D-04:** Fix GainPerLayer now in Phase 5 — compute dynamically as `1.0 / float64(len(layers))` inside `NewBank` instead of using the hardcoded `NumLayers=14` constant. This establishes the correct foundation before Phase 7 adds dynamic class counts.
### Claude's Discretion
- **D-03:** Decide whether built-in classes keep their hand-tuned HarmonicDef arrays or migrate to waveform presets. Recommended approach: keep existing harmonics as-is for v1.0 classes (preserves sound character), default them to `WaveformType = ""` (custom). Waveform presets only take effect when explicitly set via config in Phase 6.
- **D-05:** Change `NewBank` to accept a `map[classify.TrafficClass]FreqConfig` parameter instead of reading the `ClassFreqConfigs` global. This is the injection seam that Phase 6 will use to pass merged config. The existing `ClassFreqConfigs` var remains as the default map.
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| WAVE-01 | User can set waveform type per traffic class (sine, square, sawtooth, triangle) | D-02: `WaveformType` field on `FreqConfig`; `WaveformPresetHarmonics()` generates the right `[]HarmonicDef` at layer-construction time. `NewBank` iterates the injected config map, so each class can carry a distinct waveform. |
| WAVE-02 | Non-sine waveforms use bandlimited additive synthesis (no aliasing artifacts) | D-01: Harmonic truncation at Nyquist (22050 Hz) is built into `WaveformPresetHarmonics()`. Existing `Oscillator.Advance([]HarmonicDef)` already sums sine partials — waveform type only changes WHICH harmonics are passed, not the summation math. No naive waveform math is ever used. |
</phase_requirements>
---
## Summary
Phase 5 makes two independent but related changes to the `synth` package: (1) it extends the oscillator to support four waveform types via bandlimited additive synthesis, and (2) it decouples `OscillatorBank.NewBank` from the package-level `ClassFreqConfigs` global by accepting an injected config map.
Both changes are contained entirely within the `synth` package and `encode/mp3.go`. No new packages are introduced. The existing `Oscillator.Advance([]HarmonicDef)` engine already supports additive synthesis — the waveform extension simply generates different harmonic series at construction time rather than at sample-render time. The bank decoupling is a signature change to `NewBank` with a one-line follow-up in `encode/mp3.go`.
The build order is: waveform enum and `WaveformPresetHarmonics()` function first (pure math, independently testable), then wire `WaveformType` through `FreqConfig` and `NewLayer`, then change `NewBank` signature and fix `GainPerLayer`. Each step leaves existing tests green.
**Primary recommendation:** Generate bandlimited `[]HarmonicDef` slices from the waveform preset at layer construction time (inside `NewLayer` or `NewBank`) — never at sample-render time. This keeps `Oscillator.Advance` unchanged and avoids per-sample branching.
---
## Standard Stack
### Core
No new external libraries are required. All waveform math uses `math.Sin` from Go's standard library. The existing dependency set is sufficient.
| Technology | Version | Purpose | Why Standard |
|------------|---------|---------|--------------|
| `math.Sin` (stdlib) | Go 1.24 | Sine partial summation in `Oscillator.Advance` | Already the engine for all synthesis; waveform types extend what series is passed to it |
| `github.com/gopacket/gopacket` | v1.5.0 | Packet decode (unchanged) | No change — listed for completeness |
| `github.com/sjzar/go-lame` | v0.0.9 | MP3 encoding (unchanged) | No change — listed for completeness |
**Installation:** No new dependencies. `go.mod` unchanged.
---
## Architecture Patterns
### Recommended Project Structure (unchanged)
```
synth/
├── config.go FreqConfig (+ WaveformType field), HarmonicDef, ClassFreqConfigs, WaveformPresetHarmonics()
├── oscillator.go Oscillator — unchanged (Advance still takes []HarmonicDef)
├── layer.go NewLayer passes cfg.WaveformType-derived harmonics to oscillator
├── bank.go NewBank(tau, cfgs map[TrafficClass]FreqConfig) — decoupled
└── mixer.go Unchanged
encode/
└── mp3.go RunSynthesis passes synth.ClassFreqConfigs as default to NewBank
```
### Pattern 1: Bandlimited Harmonic Series Generation
**What:** A function `WaveformPresetHarmonics(waveformType WaveformType, baseHz float64, sampleRate int) []HarmonicDef` computes the correct partial series for each waveform, truncating at Nyquist to prevent aliasing. Called once at layer-construction time; result stored in the layer's oscillator call path.
**When to use:** Whenever `FreqConfig.WaveformType` is not `WaveformCustom` (the zero-value indicating hand-tuned harmonics).
**Example:**
```go
// In synth/config.go
type WaveformType int
const (
WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is
WaveformSine
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
// WaveformPresetHarmonics returns a bandlimited harmonic series for the given waveform type.
// Partials above Nyquist (sampleRate/2) are excluded to prevent aliasing.
// Returns nil if waveformType is WaveformCustom (caller uses FreqConfig.Harmonics directly).
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef {
nyquist := float64(sampleRate) / 2.0
switch wt {
case WaveformSine:
return []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
case WaveformSquare:
// Odd harmonics: 1, 3, 5, ... with amplitude 1/k, truncate at Nyquist
var defs []HarmonicDef
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
defs = append(defs, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
}
return defs
case WaveformSawtooth:
// All harmonics: 1, 2, 3, ... with amplitude 1/k, truncate at Nyquist
var defs []HarmonicDef
for k := 1; float64(k)*baseHz < nyquist; k++ {
defs = append(defs, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
}
return defs
case WaveformTriangle:
// Odd harmonics with alternating sign, amplitude 1/k^2, truncate at Nyquist
sign := 1.0
var defs []HarmonicDef
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
defs = append(defs, HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)})
sign = -sign
}
return defs
default: // WaveformCustom
return nil
}
}
```
### Pattern 2: Harmonic Resolution in NewLayer
**What:** `NewLayer` resolves which harmonic array the oscillator will use. If `cfg.WaveformType` is `WaveformCustom` (zero value), use `cfg.Harmonics`. Otherwise call `WaveformPresetHarmonics` and store the result on `Layer.Config.Harmonics` so `AdvanceSample` needs no change.
**When to use:** Every `NewLayer` call. The resolution is a one-time cost at construction.
**Example:**
```go
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
}
return &Layer{
Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate),
alpha: EMAAlpha(tau, sampleRate),
whisper: WhisperFloor,
}
}
```
`AdvanceSample` is unchanged — it still calls `l.Osc.Advance(l.Config.Harmonics)`.
### Pattern 3: NewBank Signature with Injected Config Map
**What:** `NewBank` gains a second parameter: `cfgs map[classify.TrafficClass]FreqConfig`. It iterates the map's keys to build layers, instead of ranging over `classify.AllClasses()`. `GainPerLayer` is computed dynamically from `len(cfgs)` instead of the `NumLayers` constant.
**When to use:** All callers of `NewBank`. `encode/mp3.go` passes `synth.ClassFreqConfigs` as the default, preserving v1.0 behavior.
**Example:**
```go
func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank {
b := &OscillatorBank{
layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
tau: tau,
gainPerLayer: 1.0 / float64(len(cfgs)),
}
for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau)
}
return b
}
```
`OscillatorBank` gains a `gainPerLayer float64` field. `RenderWindow` uses `b.gainPerLayer` instead of the package-level `GainPerLayer` constant. The constant `GainPerLayer` and `NumLayers` can be deprecated (kept for any external referencing tests but no longer used in bank logic).
`RenderWindow` iterates `b.layers` directly instead of `classify.AllClasses()`:
```go
for _, layer := range b.layers {
sample := layer.AdvanceSample()
gainL, gainR := PanGains(layer.Config.Pan)
sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR
}
```
**Note:** `RenderWindow` currently also iterates `classify.AllClasses()` when calling `UpdateTarget`. This must also change to iterate the `snap.Counts` map (or iterate `b.layers` keys and look up each class in `snap.Counts`):
```go
for class, layer := range b.layers {
count := snap.Counts[class]
layer.UpdateTarget(count, maxCount)
}
```
### Anti-Patterns to Avoid
- **Generating harmonics at sample-render time:** Do not call `WaveformPresetHarmonics` inside `Oscillator.Advance` or `Layer.AdvanceSample`. This costs ~10 allocations per frame at 44100 Hz and changes the per-sample hot path. Generate once at construction time.
- **Adding a new oscillator type per waveform:** The existing `Oscillator` + `[]HarmonicDef` is already a general additive engine. A new `SquareOscillator` type would duplicate phase management, EMA wiring, and all tests. There is no need.
- **Removing the `NumLayers` and `GainPerLayer` constants immediately:** Tests in `synth/config_test.go` (specifically `TestNumLayersMatchesAllClasses`) reference `synth.NumLayers`. The constant must remain exported (even if bank no longer uses it internally) until the test is updated. Update the test as part of D-04.
- **Iterating `classify.AllClasses()` in RenderWindow:** After D-05, `b.layers` is the authoritative set of active classes. The two remaining loops in `RenderWindow` that range over `classify.AllClasses()` must both change to iterate `b.layers`, or they will break when Phase 7 adds user-defined classes that are not in `AllClasses()`.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Aliasing-free waveforms | Direct time-domain `sign(sin(phase))`, `2*frac(phase)-1` | Bandlimited additive synthesis via `WaveformPresetHarmonics()` | Direct math has infinite harmonics; aliases above Nyquist fold into the audible range as buzzing distortion — worst at 330 Hz+ (SSH, SMTP, DHCP) |
| Per-sample waveform dispatch | `switch waveform { case square: return sign(sin(...)) }` in `Advance()` | Preset `[]HarmonicDef` computed at construction | Avoids per-sample branching; reuses existing `Oscillator.Advance` without any signature change |
| Dynamic gain normalization | Hand-derive scaling formula per class count | `1.0 / float64(len(cfgs))` | Already the correct formula; the existing `NumLayers=14` constant was a specialization of this |
**Key insight:** The additive synthesis engine (`Oscillator.Advance([]HarmonicDef)`) is already general. Waveform type support is purely a matter of which harmonic series you feed it, not how the oscillator itself works.
---
## Common Pitfalls
### Pitfall 1: Naive Waveform Math Produces Audible Aliasing (Pitfall A3)
**What goes wrong:** Implementing `square(phase) = sign(sin(2π·phase))` or `sawtooth(phase) = 2·frac(phase) - 1` directly. These have infinite harmonics; above Nyquist they fold back into the audible range as aliasing. At SSH (330 Hz) and higher, the effect is audible buzzing that sounds like corruption.
**Why it happens:** The mathematical waveforms are not bandlimited. Sampling them at 44100 Hz aliases all energy above 22050 Hz back into audible frequencies.
**How to avoid:** Use `WaveformPresetHarmonics()` which truncates the harmonic series at `float64(k)*baseHz < nyquist`. The existing `Oscillator.Advance` sums sinusoids, which are already bandlimited by nature.
**Warning signs:** Square/sawtooth sounds buzzy or harsh at frequencies above ~300 Hz. Aliasing cannot be removed after the fact.
### Pitfall 2: Generating Harmonics at Sample-Render Time
**What goes wrong:** Calling `WaveformPresetHarmonics()` inside `Advance()` or `AdvanceSample()` on every sample. At 44100 Hz per channel this creates 44100 slice allocations per second, causing GC pressure and measurable latency in the render loop.
**Why it happens:** Placing the preset logic in `Advance` seems clean because it keeps the oscillator self-contained.
**How to avoid:** Resolve harmonics once in `NewLayer` (at construction). Store the result in `Layer.Config.Harmonics`. `AdvanceSample` needs no change.
**Warning signs:** CPU profile shows allocations in `synth.WaveformPresetHarmonics` during `RenderWindow`.
### Pitfall 3: Both RenderWindow Loops Still Iterate classify.AllClasses()
**What goes wrong:** `RenderWindow` has two loops that call `classify.AllClasses()`: one for `UpdateTarget` and one for rendering. After `NewBank` switches to iterating the injected map, if both `RenderWindow` loops still use `classify.AllClasses()`, Phase 7 user-defined classes will aggregate counts but never have their target updated, producing silence with no error.
**Why it happens:** Updating `NewBank`'s construction loop is the obvious change; the two `RenderWindow` loops are easy to miss.
**How to avoid:** Change all three loops in `bank.go` simultaneously. Use `for class, layer := range b.layers` in both `RenderWindow` loops.
**Warning signs:** User-defined class layers produce silence when traffic is present (Phase 7 symptom), or `TestMixerNoClip` fails if the layer count changes.
### Pitfall 4: TestNewBankHas14Layers and TestNumLayersMatchesAllClasses Break Without Updates
**What goes wrong:** `synth/bank_test.go:TestNewBankHas14Layers` calls `NewBank(1.0)` with the old one-argument signature. `synth/config_test.go:TestNumLayersMatchesAllClasses` asserts `synth.NumLayers == len(classify.AllClasses())`. Both tests fail on compile or assertion the moment `NewBank` gains a parameter.
**Why it happens:** These tests were written against the v1.0 API.
**How to avoid:** Update both tests as part of the same commit that changes `NewBank`. `TestNewBankHas14Layers` should call `NewBank(1.0, synth.ClassFreqConfigs)`. `TestNumLayersMatchesAllClasses` should be updated to assert `len(synth.ClassFreqConfigs) == len(classify.AllClasses())` or deleted if the invariant is no longer meaningful.
**Warning signs:** Compile error on `NewBank(1.0)` after the signature change.
### Pitfall 5: Triangle Wave Amplitude Is Much Lower Than Other Waveforms
**What goes wrong:** Triangle uses `1/k^2` amplitude rolloff (vs `1/k` for square/sawtooth). The total weight of the normalized series is much lower (sum of `1/k^2` for odd k converges to `π^2/8 ≈ 1.23` vs `π/4 ≈ 0.79` for square), but after normalization in `Oscillator.Advance` (`sum / totalWeight`) the peak amplitude is ~1.0. However, because fewer harmonics contribute significantly, the RMS energy is lower than a square wave at the same amplitude setting. This means triangle layers sound subjectively quieter even at the same volume setting.
**Why it happens:** The 1/k^2 rolloff is acoustically intentional (triangle is the smoothest non-sine waveform) but it may surprise developers comparing oscilloscope peak values vs perceived loudness.
**How to avoid:** This is a design characteristic, not a bug. Document it. If perceptual loudness matching is needed in Phase 6, the user can adjust the `GainPerLayer` or per-class amplitude in config. Do not "fix" by changing amplitudes — that would break the standard triangle wave definition.
**Warning signs:** Triangle-waveform layer sounds noticeably quieter than square/sawtooth at the same traffic level.
---
## Code Examples
Verified patterns from direct code inspection of the existing codebase:
### How Oscillator.Advance Currently Works (unchanged)
```go
// synth/oscillator.go — existing, unchanged by this phase
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
}
```
The normalization (`sum / totalWeight`) ensures the output is bounded in [-1, 1] regardless of how many partials are summed. Waveform presets with `1/k` amplitudes naturally produce a well-normalized output from this engine.
### Partial Count vs Frequency for Phase 5 Presets
At 44100 Hz sample rate (Nyquist = 22050 Hz):
| Waveform | BaseHz | Max Partial | Partial Count |
|----------|--------|-------------|---------------|
| Square | 65 Hz | k=675 (odd) | ~338 partials |
| Square | 1047 Hz | k=41 (odd) | ~21 partials |
| Sawtooth | 65 Hz | k=339 | 339 partials |
| Sawtooth | 1047 Hz | k=21 | 21 partials |
| Triangle | 65 Hz | k=675 (odd) | ~338 partials |
| Triangle | 1047 Hz | k=41 (odd) | ~21 partials |
The D-01 decision specifies "8-12 partials" as a practical cap. The Nyquist-truncation formula above naturally produces more partials for low-frequency oscillators. The planner should consider whether to implement a hard cap at 12 partials (simpler, slightly more aliasing at very low frequencies) or use the full Nyquist-truncated series (more accurate, still inaudible aliasing). Both are correct implementations of WAVE-02.
**Recommendation (Claude's Discretion):** Use the Nyquist-truncation formula without an additional hard cap. For very low-frequency bases (65 Hz), 300+ partials is still fast in the inner loop since the sum is simple float64 multiply-and-add. The audible difference between 12 and 300 partials at 65 Hz is significant; the 12-partial cap would noticeably affect sound character. Reserve the 8-12 cap language as an approximation, not an implementation constraint.
### encode/mp3.go Change (the only caller of NewBank)
```go
// encode/mp3.go — current call
bank := synth.NewBank(1.0)
// encode/mp3.go — updated call (passes default config, behavior identical)
bank := synth.NewBank(1.0, synth.ClassFreqConfigs)
```
This is the only external call site. No other files reference `synth.NewBank`.
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `NumLayers=14` hardcoded constant for gain scaling | `1.0 / float64(len(cfgs))` computed dynamically | Phase 5 (D-04) | Gain scaling stays correct as class count varies in Phase 7 |
| `NewBank` reads `ClassFreqConfigs` global | `NewBank(tau, cfgs)` accepts injected map | Phase 5 (D-05) | Bank is now testable without global mutation; Phase 6 can pass merged configs |
| Sine-only oscillator | Four waveform types via bandlimited additive synthesis | Phase 5 | User-selectable timbres per traffic class; WAVE-01/02 satisfied |
**Deprecated/outdated after this phase:**
- `NumLayers` constant: still exported but no longer used in bank logic. Can be removed in a cleanup phase.
- `GainPerLayer` constant: same status as `NumLayers`.
- `bank.go` ranging over `classify.AllClasses()`: replaced by ranging over `b.layers` in all three loops.
---
## Environment Availability
Step 2.6: SKIPPED — phase is purely code changes within the existing Go module. No external tools, services, runtimes, databases, or CLIs beyond the project's own build toolchain are required. Existing `go test ./synth/...` confirms the baseline passes.
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go testing (`testing` stdlib) |
| Config file | None — standard `go test` |
| Quick run command | `go test ./synth/... ./encode/...` |
| Full suite command | `go test ./...` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| WAVE-01 | WaveformType field added to FreqConfig; zero value (WaveformCustom) preserves existing behavior | unit | `go test ./synth/... -run TestWaveformCustomPreservesHarmonics` | ❌ Wave 0 |
| WAVE-01 | WaveformPresetHarmonics returns correct partial series for square, sawtooth, triangle, sine | unit | `go test ./synth/... -run TestWaveformPresetHarmonics` | ❌ Wave 0 |
| WAVE-01 | NewBank accepts injected config map; layer count equals map size | unit | `go test ./synth/... -run TestNewBankAcceptsConfigMap` | ❌ Wave 0 (replaces TestNewBankHas14Layers) |
| WAVE-02 | All partials in preset harmonic series are below Nyquist (sampleRate/2) | unit | `go test ./synth/... -run TestBandlimitedHarmonicsNoAliasing` | ❌ Wave 0 |
| WAVE-02 | Sine waveform (WaveformSine preset) produces same output as single-harmonic custom config | unit | `go test ./synth/... -run TestSineRegressionVsCustomHarmonics` | ❌ Wave 0 |
| WAVE-01+02 | GainPerLayer computed dynamically; no clip with N-class config map | unit | `go test ./synth/... -run TestMixerNoClip` | ✅ exists (update to new NewBank signature) |
| WAVE-01 | encode.RunSynthesis compiles and passes synth.ClassFreqConfigs to NewBank | unit/smoke | `go test ./encode/...` | ✅ exists (update call site) |
### Sampling Rate
- **Per task commit:** `go test ./synth/... ./encode/...`
- **Per wave merge:** `go test ./...`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `synth/waveform_test.go` (or additions to `synth/oscillator_test.go`) — covers WAVE-01 (preset harmonics correctness) and WAVE-02 (bandlimit enforcement)
- [ ] Update `synth/bank_test.go:TestNewBankHas14Layers` to use new two-argument `NewBank` signature
- [ ] Update `synth/config_test.go:TestNumLayersMatchesAllClasses` to reflect dynamic gain approach
---
## Open Questions
1. **Hard cap on partial count (8-12 partials per D-01 vs Nyquist truncation)**
- What we know: D-01 says "8-12 partials." Nyquist truncation produces up to ~340 partials for a 65 Hz sawtooth. Both approaches satisfy WAVE-02.
- What's unclear: Was "8-12 partials" a maximum cap or a minimum floor for realistic waveforms?
- Recommendation: Use Nyquist truncation without hard cap. At 44100 Hz the summation loop is fast. Document the choice. If the user hears no meaningful difference between 12 and 340 partials at 65 Hz (perceptually similar) then reconsider in Phase 6 when user testing begins.
2. **TestHarmonicsNonEmpty breaks if WaveformCustom harmonics are empty for a class**
- What we know: `synth/config_test.go:TestHarmonicsNonEmpty` asserts every `ClassFreqConfigs` entry has `len(cfg.Harmonics) >= 2`. All built-in entries retain their hand-tuned harmonics (D-03), so this test continues to pass.
- What's unclear: If a future entry in `ClassFreqConfigs` uses `WaveformType = WaveformSine` with an empty `Harmonics` slice, the test would fail. This is not a Phase 5 concern since D-03 says keep existing harmonics as-is.
- Recommendation: No action needed in Phase 5. Note for Phase 6 if user-configured classes with preset waveforms and empty Harmonics are added to the default config.
---
## Sources
### Primary (HIGH confidence)
- Direct code inspection: `synth/oscillator.go`, `synth/config.go`, `synth/bank.go`, `synth/layer.go`, `synth/bank_test.go`, `synth/config_test.go`, `synth/oscillator_test.go`, `encode/mp3.go` — exact current implementation confirmed
- `.planning/research/PITFALLS.md` — Pitfall A3 (aliasing), verified against DSP literature in that document
- `.planning/research/ARCHITECTURE.md` — Integration point analysis, build order, confirmed against actual code
### Secondary (MEDIUM confidence)
- `.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md` — User decisions D-01 through D-05
- DSP theory: harmonic series for square (odd, 1/k), sawtooth (all, 1/k), triangle (odd, alternating sign, 1/k^2) — standard result, confirmed in PITFALLS.md sources (WolfSound, CCRMA, McGill)
### Tertiary (LOW confidence)
None. All findings grounded in direct code inspection or established DSP theory.
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new libraries; all changes are within existing codebase
- Architecture: HIGH — based on direct inspection of all affected files; build order verified against existing test structure
- Pitfalls: HIGH — aliasing pitfall from DSP literature; API-break pitfalls from direct test-file inspection
**Research date:** 2026-03-26
**Valid until:** Stable — pure Go math and internal refactor; no external API dependencies that could change
@@ -0,0 +1,81 @@
---
phase: 5
slug: waveform-types-and-bank-decoupling
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-26
---
# Phase 5 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go testing (`testing` stdlib) |
| **Config file** | None — standard `go test` |
| **Quick run command** | `go test ./synth/... ./encode/...` |
| **Full suite command** | `go test ./...` |
| **Estimated runtime** | ~5 seconds |
---
## Sampling Rate
- **After every task commit:** Run `go test ./synth/... ./encode/...`
- **After every plan wave:** Run `go test ./...`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 5 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 05-01-01 | 01 | 1 | WAVE-01 | unit | `go test ./synth/... -run TestWaveformCustomPreservesHarmonics` | ❌ W0 | ⬜ pending |
| 05-01-02 | 01 | 1 | WAVE-01 | unit | `go test ./synth/... -run TestWaveformPresetHarmonics` | ❌ W0 | ⬜ pending |
| 05-01-03 | 01 | 1 | WAVE-01 | unit | `go test ./synth/... -run TestNewBankAcceptsConfigMap` | ❌ W0 | ⬜ pending |
| 05-01-04 | 01 | 1 | WAVE-02 | unit | `go test ./synth/... -run TestBandlimitedHarmonicsNoAliasing` | ❌ W0 | ⬜ pending |
| 05-01-05 | 01 | 1 | WAVE-02 | unit | `go test ./synth/... -run TestSineRegressionVsCustomHarmonics` | ❌ W0 | ⬜ pending |
| 05-02-01 | 02 | 1 | WAVE-01+02 | unit | `go test ./synth/... -run TestMixerNoClip` | ✅ exists (update) | ⬜ pending |
| 05-02-02 | 02 | 1 | WAVE-01 | unit/smoke | `go test ./encode/...` | ✅ exists (update) | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `synth/waveform_test.go` — stubs for WAVE-01 (preset harmonics correctness) and WAVE-02 (bandlimit enforcement)
- [ ] Update `synth/bank_test.go:TestNewBankHas14Layers` to use new two-argument `NewBank` signature
- [ ] Update `synth/config_test.go:TestNumLayersMatchesAllClasses` to reflect dynamic gain approach
*Existing test infrastructure covers framework and tooling — no new framework install needed.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Audible tonal distinction between waveforms | WAVE-01 | Subjective audio quality | Generate MP3 with each waveform type; listen and confirm distinct timbres |
| No audible aliasing or buzzing | WAVE-02 | Perceptual audio quality | Play sawtooth/square at low frequencies (65 Hz); confirm clean sound |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 5s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
@@ -0,0 +1,126 @@
---
phase: 05-waveform-types-and-bank-decoupling
verified: 2026-03-26T00:00:00Z
status: passed
score: 12/12 must-haves verified
re_verification: false
gaps: []
human_verification: []
---
# Phase 5: Waveform Types and Bank Decoupling Verification Report
**Phase Goal:** Add waveform types (sine, square, sawtooth, triangle) with bandlimited synthesis; decouple OscillatorBank from global config for custom sound mapping injection.
**Verified:** 2026-03-26
**Status:** passed
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths
Plan 01 truths:
| # | Truth | Status | Evidence |
|----|-------|--------|----------|
| 1 | WaveformType enum exists with five values: WaveformCustom (0), WaveformSine, WaveformSquare, WaveformSawtooth, WaveformTriangle | VERIFIED | `synth/config.go` lines 16-24: `type WaveformType int` with five `iota` constants in correct order |
| 2 | WaveformPresetHarmonics returns correct bandlimited harmonic series for each waveform type | VERIFIED | `synth/config.go` lines 29-59: correct loop logic for each waveform; all 9 waveform tests pass |
| 3 | All generated partials are below Nyquist frequency (22050 Hz) | VERIFIED | `TestBandlimitedHarmonicsNoAliasing` iterates all ClassFreqConfigs × all 4 waveform types — passes |
| 4 | WaveformCustom returns nil, preserving existing hand-tuned harmonics | VERIFIED | `synth/config.go` line 33: `case WaveformCustom: return nil`; `TestWaveformPresetHarmonics_Custom` passes |
| 5 | NewLayer resolves waveform presets at construction time, not at render time | VERIFIED | `synth/layer.go` lines 25-27: preset resolution at top of `NewLayer`; `TestNewLayerResolvesWaveformPreset` and `TestSineRegressionVsCustomHarmonics` pass |
| 6 | Existing tests still pass — no regression in v1.0 behavior | VERIFIED | `go test ./...` — all 6 packages pass (aggregate, capture, classify, cmd/netsynth, encode, synth) |
Plan 02 truths:
| # | Truth | Status | Evidence |
|----|-------|--------|----------|
| 7 | NewBank accepts a config map parameter instead of reading the ClassFreqConfigs global | VERIFIED | `synth/bank.go` line 17: `func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank` |
| 8 | GainPerLayer is computed dynamically as 1.0/len(configs) inside NewBank | VERIFIED | `synth/bank.go` line 21: `gainPerLayer: 1.0 / float64(len(cfgs))`; `TestNewBankDynamicGain` asserts `b.gainPerLayer == 1.0/3.0` for 3-class config |
| 9 | RenderWindow iterates b.layers instead of classify.AllClasses() in both loops | VERIFIED | `synth/bank.go` lines 42-55: both loops use `range b.layers`; `classify.AllClasses()` absent from bank.go |
| 10 | encode.RunSynthesis passes synth.ClassFreqConfigs as the default config map | VERIFIED | `encode/mp3.go` line 57: `bank := synth.NewBank(1.0, synth.ClassFreqConfigs)` |
| 11 | All 14 built-in classes still produce the same audio output as v1.0 | VERIFIED | `TestNewBankHas14Layers`, `TestMixerNoClip`, `TestMultipleWindowsEMAConvergence`, `TestStereoPan` all pass |
| 12 | No-clip guarantee holds with dynamic gain scaling | VERIFIED | `TestMixerNoClip` (14-class), `TestNewBankCustomConfigNoClip` (2-class) both pass |
**Score:** 12/12 truths verified
---
### Required Artifacts
| Artifact | Provides | Status | Details |
|----------|----------|--------|---------|
| `synth/config.go` | WaveformType enum and WaveformPresetHarmonics function | VERIFIED | Exports all 5 enum values, `WaveformPresetHarmonics`, and `FreqConfig.WaveformType` field |
| `synth/layer.go` | Waveform resolution in NewLayer | VERIFIED | Lines 25-27 resolve presets at construction; `WaveformPresetHarmonics` called correctly |
| `synth/waveform_test.go` | Tests for waveform preset generation and bandlimiting | VERIFIED | 12 test functions including all specified behavioral tests |
| `synth/bank.go` | Decoupled OscillatorBank with injected config map | VERIFIED | `gainPerLayer` field present, `NewBank` takes `cfgs` param, both `RenderWindow` loops use `b.layers` |
| `encode/mp3.go` | Updated NewBank call site | VERIFIED | Line 57 passes `synth.ClassFreqConfigs` as second arg |
| `synth/bank_test.go` | Updated tests for new NewBank signature | VERIFIED | All calls are two-argument; `TestNewBankDynamicGain` and `TestNewBankCustomConfigNoClip` present |
| `synth/config_test.go` | Updated TestNumLayersMatchesAllClasses | VERIFIED | Line 62: asserts `len(synth.ClassFreqConfigs) == len(classify.AllClasses())`; no reference to `synth.NumLayers` |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `synth/layer.go` | `synth/config.go` | `NewLayer` calls `WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)` | WIRED | Line 26: exact call present; conditional on `cfg.WaveformType != WaveformCustom` |
| `encode/mp3.go` | `synth/bank.go` | `synth.NewBank(1.0, synth.ClassFreqConfigs)` | WIRED | Line 57: exact pattern matches; no single-arg NewBank calls anywhere in codebase |
| `synth/bank.go` | `synth/layer.go` | `NewLayer(cfg, SampleRate, tau)` for each config map entry | WIRED | Lines 23-25: iterates `cfgs`, calls `NewLayer(cfg, SampleRate, tau)` for each |
| `synth/bank.go` | `synth/config.go` | `gainPerLayer` computed from `len(cfgs)` | WIRED | Line 21: `1.0 / float64(len(cfgs))` |
---
### Data-Flow Trace (Level 4)
Not applicable. Phase 5 artifacts are synthesis engine components (type definitions, pure functions, struct methods) — not UI components or pages that render dynamic data from an external source. Data flow is exercised directly by the test suite.
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| All synth tests pass including new waveform tests | `go test ./synth/... -v -count=1` | 30 tests pass, 0 failures | PASS |
| Full project builds without errors | `go build ./...` | Exit 0, no output | PASS |
| go vet finds no issues | `go vet ./synth/... ./encode/...` | Exit 0, no output | PASS |
| Full test suite passes | `go test ./...` | 6 packages pass, 0 failures | PASS |
---
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| WAVE-01 | 05-01, 05-02 | User can set waveform type per traffic class (sine, square, sawtooth, triangle) | SATISFIED | `WaveformType` field on `FreqConfig`; `NewBank` accepts any config map with any `WaveformType` per entry; waveform resolution in `NewLayer` |
| WAVE-02 | 05-01, 05-02 | Non-sine waveforms use bandlimited additive synthesis (no aliasing artifacts) | SATISFIED | `WaveformPresetHarmonics` loops terminate at `float64(k)*baseHz < nyquist`; `TestBandlimitedHarmonicsNoAliasing` verifies no harmonic exceeds 22050 Hz across all base frequencies |
No orphaned requirements: REQUIREMENTS.md traceability table maps WAVE-01 and WAVE-02 to Phase 5 only; both are covered.
---
### Anti-Patterns Found
None. Grep scan of all phase-modified files (`synth/config.go`, `synth/layer.go`, `synth/bank.go`, `synth/waveform_test.go`, `synth/bank_test.go`, `synth/config_test.go`, `encode/mp3.go`) found no TODO/FIXME/placeholder comments, no empty implementations, no hardcoded empty returns, and no stubbed handlers.
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | — | — | — |
---
### Human Verification Required
None. All phase-5 behaviors are exercised by automated tests with deterministic numeric assertions. No visual rendering, real-time playback, or external service integration was introduced.
---
### Gaps Summary
No gaps. All 12 must-have truths are verified. Both requirement IDs (WAVE-01, WAVE-02) are satisfied. The full test suite passes with zero failures across all packages.
---
_Verified: 2026-03-26_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,323 @@
---
phase: 06-config-package-and-sound-overrides
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- config/config.go
- config/config_test.go
- go.mod
- go.sum
autonomous: true
requirements:
- CFG-01
- CFG-02
- CFG-04
- CFG-05
must_haves:
truths:
- "Load with explicit path to valid TOML returns merged config map with overrides applied"
- "Load with no config file found returns default ClassFreqConfigs unchanged"
- "Load with unknown TOML key returns error naming the bad key"
- "Load with partial override (only frequency set) leaves waveform unchanged"
- "Load with partial override (only waveform set) leaves frequency unchanged"
- "Load with unknown class name logs warning and does not error"
artifacts:
- path: "config/config.go"
provides: "Load function, parse, validate, merge, discover"
exports: ["Load"]
- path: "config/config_test.go"
provides: "Table-driven tests for CFG-01 through CFG-05"
min_lines: 100
key_links:
- from: "config/config.go"
to: "synth/config.go"
via: "imports synth.FreqConfig, synth.WaveformType, synth.ClassFreqConfigs, synth.WaveformPresetHarmonics"
pattern: "synth\\.FreqConfig|synth\\.ClassFreqConfigs|synth\\.WaveformPresetHarmonics"
- from: "config/config.go"
to: "classify/types.go"
via: "imports classify.TrafficClass, classify.AllClasses"
pattern: "classify\\.TrafficClass|classify\\.AllClasses"
- from: "config/config.go"
to: "github.com/BurntSushi/toml"
via: "toml.DecodeFile, md.Undecoded()"
pattern: "toml\\.DecodeFile|Undecoded"
---
<objective>
Create the `config` package with TOML loading, unknown-key validation, partial-merge semantics, and auto-discovery logic. This is the core of Phase 6 — all config behavior except CLI flag wiring.
Purpose: Implements CFG-01 (TOML override), CFG-02 (auto-discovery), CFG-04 (partial override), CFG-05 (unknown key error). The package exposes a single `Load(configPath string)` function that returns a ready-to-use `map[classify.TrafficClass]synth.FreqConfig`.
Output: `config/config.go`, `config/config_test.go`, updated `go.mod`/`go.sum`
</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/06-config-package-and-sound-overrides/06-CONTEXT.md
@.planning/phases/06-config-package-and-sound-overrides/06-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From synth/config.go:
```go
type WaveformType int
const (
WaveformCustom WaveformType = iota
WaveformSine
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
type HarmonicDef struct {
Ratio int
Amplitude float64
}
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ /* 14 entries */ }
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef
const SampleRate = 44100
```
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"
ClassUnknown1 TrafficClass = "unknown-1"
ClassUnknown2 TrafficClass = "unknown-2"
ClassUnknown3 TrafficClass = "unknown-3"
ClassUnknown4 TrafficClass = "unknown-4"
)
func AllClasses() []TrafficClass
```
Go module path: `github.com/netsynth/netsynth`
</interfaces>
</context>
<feature>
<name>Config package: TOML load, validate, merge</name>
<files>config/config.go, config/config_test.go</files>
<behavior>
- Test: Load(explicitPath) with valid TOML `[sounds.ICMP]\nfrequency = 100.0` returns map where ICMP.BaseHz == 100.0 and all other classes unchanged (CFG-01, CFG-04)
- Test: Load(explicitPath) with `[sounds.ICMP]\nwaveform = "square"` returns map where ICMP.WaveformType == WaveformSquare and ICMP.BaseHz unchanged (CFG-04)
- Test: Load(explicitPath) with `[sounds.ICMP]\nfrequncy = 440` returns error containing "frequncy" (CFG-05)
- Test: Load("") in a directory with no netsynth.toml returns default ClassFreqConfigs map with no error (CFG-02)
- Test: Load(explicitPath) where file does not exist returns error containing "not found" (CFG-03 prep)
- Test: Load(explicitPath) with `[sounds.BOGUS]\nfrequency = 100.0` returns no error but stderr contains "unknown class" (D-09)
- Test: Load(explicitPath) with `[sounds.ICMP]\nfrequency = 100.0\nwaveform = "square"` returns ICMP with both overrides applied and harmonics regenerated
- Test: Load(explicitPath) with `[sounds.ICMP]\nwaveform = "invalid"` returns error containing "invalid waveform"
- Test: All 14 default classes present in result map regardless of override count
</behavior>
<implementation>
Create `config/config.go` with:
1. `SoundOverride` struct with pointer fields `Frequency *float64` and `Waveform *string` (toml tags)
2. `rawConfig` struct with `Sounds map[string]SoundOverride` (toml tag "sounds")
3. `Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)` — public entry point
4. `resolvePath(configPath string) (path string, explicit bool, err error)` — handles D-05 discovery order
5. `discoverPath() string` — probes ./netsynth.toml then ~/.config/netsynth/config.toml
6. `parseFile(path string) (rawConfig, error)` — uses toml.DecodeFile + md.Undecoded() for CFG-05
7. `validate(raw rawConfig) error` — validates waveform strings
8. `merge(defaults map[classify.TrafficClass]synth.FreqConfig, overrides map[string]SoundOverride) map[classify.TrafficClass]synth.FreqConfig` — per-field overlay
9. `copyDefaults() map[classify.TrafficClass]synth.FreqConfig` — shallow copy of ClassFreqConfigs
10. `parseWaveform(s string) (synth.WaveformType, error)` — string-to-enum map
11. `validWaveforms` map: "sine"->WaveformSine, "square"->WaveformSquare, "sawtooth"->WaveformSawtooth, "triangle"->WaveformTriangle
Add `github.com/BurntSushi/toml@v1.6.0` to go.mod via `go get`.
Per D-03: Per-field overlay merge — only non-nil pointer fields override defaults.
Per D-07: Unknown keys detected via md.Undecoded(), error names the key.
Per D-09: Unknown class names in [sounds.<name>] produce warning to stderr, not error.
Per D-05: Discovery order: --config > ./netsynth.toml > ~/.config/netsynth/config.toml.
Per D-06: Only one config file loaded, first found wins.
Per D-08: Type mismatches produce clear error with field name.
Per D-11: All validation happens before returning — fail fast.
When frequency is overridden and WaveformType != WaveformCustom, regenerate Harmonics via WaveformPresetHarmonics(cfg.WaveformType, newBaseHz, synth.SampleRate).
When waveform is overridden, regenerate Harmonics via WaveformPresetHarmonics(newWt, cfg.BaseHz, synth.SampleRate).
</implementation>
</feature>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Config package — TDD red-green-refactor</name>
<files>config/config.go, config/config_test.go, go.mod, go.sum</files>
<read_first>
synth/config.go (FreqConfig, WaveformType, ClassFreqConfigs, WaveformPresetHarmonics, SampleRate)
classify/types.go (TrafficClass, AllClasses, class constants)
go.mod (current dependencies)
.planning/phases/06-config-package-and-sound-overrides/06-RESEARCH.md (patterns 1-5, pitfalls 1-4)
</read_first>
<behavior>
- TestLoadPartialOverrideFrequency: Load TOML `[sounds.ICMP]\nfrequency = 100.0` -> ICMP.BaseHz == 100.0, ICMP.WaveformType == synth.WaveformCustom (unchanged), DNS.BaseHz == 110.0 (unchanged)
- TestLoadPartialOverrideWaveform: Load TOML `[sounds.ICMP]\nwaveform = "square"` -> ICMP.WaveformType == synth.WaveformSquare, ICMP.BaseHz == 65.0 (unchanged), len(ICMP.Harmonics) > 0
- TestLoadBothOverrides: Load TOML `[sounds.ICMP]\nfrequency = 100.0\nwaveform = "square"` -> ICMP.BaseHz == 100.0, ICMP.WaveformType == synth.WaveformSquare
- TestLoadUnknownKey: Load TOML `[sounds.ICMP]\nfrequncy = 440` -> error != nil, error contains "frequncy"
- TestLoadNoConfig: Load("") in temp dir with no netsynth.toml -> err == nil, result has 14 entries, ICMP.BaseHz == 65.0
- TestLoadExplicitMissing: Load("/nonexistent/file.toml") -> error != nil, error contains "not found"
- TestLoadUnknownClass: Load TOML `[sounds.BOGUS]\nfrequency = 100.0` -> err == nil, result has 14 entries (BOGUS not present)
- TestLoadInvalidWaveform: Load TOML `[sounds.ICMP]\nwaveform = "invalid"` -> error != nil, error contains "invalid waveform"
- TestLoadAllDefaultsPresent: Load with any valid override -> len(result) == 14
</behavior>
<action>
**RED phase:** Create `config/config_test.go` with all 9 test functions listed in behavior above. Each test:
- Creates a temp TOML file with `os.CreateTemp(t.TempDir(), "*.toml")`
- Calls `config.Load(tmpFile.Name())` (or `config.Load("")` for no-config test)
- Asserts expected outcomes
For TestLoadNoConfig: use `t.Chdir(t.TempDir())` (Go 1.24 testing.T.Chdir) to ensure no netsynth.toml exists in working directory.
Create a minimal `config/config.go` with just `package config` and a stub `Load` function returning nil, nil so the test file compiles. Run `go test ./config/... -count=1` — all tests must FAIL (red).
**GREEN phase:** Implement `config/config.go` fully:
1. Run `go get github.com/BurntSushi/toml@v1.6.0` to add the dependency.
2. Package declaration and imports:
```
package config
imports: errors, fmt, io/fs, os, path/filepath, strings
github.com/BurntSushi/toml
github.com/netsynth/netsynth/classify
github.com/netsynth/netsynth/synth
```
3. Types:
- `SoundOverride` struct: `Frequency *float64 \`toml:"frequency"\``, `Waveform *string \`toml:"waveform"\``
- `rawConfig` struct: `Sounds map[string]SoundOverride \`toml:"sounds"\``
4. `validWaveforms` var: map[string]synth.WaveformType with entries "sine"->WaveformSine, "square"->WaveformSquare, "sawtooth"->WaveformSawtooth, "triangle"->WaveformTriangle
5. `Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)`:
- Call resolvePath(configPath) -> path, explicit, err
- If err != nil, return nil, err
- If path == "", return copyDefaults(), nil (CFG-02: silent default)
- Call parseFile(path) -> raw, err
- If err != nil AND explicit AND errors.Is(err, fs.ErrNotExist): return nil, fmt.Errorf("config file not found: %s", configPath)
- If err != nil (other): return nil, err
- Call validate(raw) -> err; if err, return nil, err
- Return merge(copyDefaults(), raw.Sounds), nil
6. `resolvePath(configPath string) (string, bool, error)`:
- If configPath != "": return configPath, true, nil
- path := discoverPath()
- return path, false, nil
7. `discoverPath() string`:
- Check `os.Stat("netsynth.toml")` — if err == nil, return "netsynth.toml"
- dir, err := os.UserConfigDir(); if err != nil, return ""
- p := filepath.Join(dir, "netsynth", "config.toml")
- Check os.Stat(p) — if err == nil, return p
- return ""
8. `parseFile(path string) (rawConfig, error)`:
- var raw rawConfig
- md, err := toml.DecodeFile(path, &raw)
- If err != nil, return raw, err (this handles file-not-found and parse errors including D-08 type mismatches)
- undecoded := md.Undecoded()
- If len(undecoded) > 0: keyPath := strings.Join(undecoded[0].String() ... ) — actually undecoded is []toml.Key where Key is []string. Use `undecoded[0].String()` which returns dot-joined path. Return raw, fmt.Errorf("config: unknown key %q — check spelling", undecoded[0].String())
- Return raw, nil
9. `validate(raw rawConfig) error`:
- For each className, override in raw.Sounds:
- If override.Waveform != nil: call parseWaveform(*override.Waveform); if err, return err
10. `parseWaveform(s string) (synth.WaveformType, error)`:
- If wt, ok := validWaveforms[s]; ok: return wt, nil
- valid := []string{"sine", "square", "sawtooth", "triangle"}
- Return 0, fmt.Errorf("config: invalid waveform %q — valid values: %s", s, strings.Join(valid, ", "))
11. `copyDefaults() map[classify.TrafficClass]synth.FreqConfig`:
- result := make(map[...], len(synth.ClassFreqConfigs))
- For k, v := range synth.ClassFreqConfigs: result[k] = v
- Return result
- Comment: "Shallow copy is safe because merge assigns fresh Harmonics slices from WaveformPresetHarmonics, never mutates the original."
12. `merge(defaults map[classify.TrafficClass]synth.FreqConfig, overrides map[string]SoundOverride) map[classify.TrafficClass]synth.FreqConfig`:
- For className, override := range overrides:
- class := classify.TrafficClass(className)
- cfg, known := defaults[class]
- If !known: fmt.Fprintf(os.Stderr, "Warning: config: unknown class %q (ignored)\n", className); continue (D-09)
- If override.Frequency != nil:
- cfg.BaseHz = *override.Frequency
- If cfg.WaveformType != synth.WaveformCustom: cfg.Harmonics = synth.WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, synth.SampleRate)
- If override.Waveform != nil:
- wt, _ := parseWaveform(*override.Waveform) (already validated)
- cfg.WaveformType = wt
- cfg.Harmonics = synth.WaveformPresetHarmonics(wt, cfg.BaseHz, synth.SampleRate)
- defaults[class] = cfg
- Return defaults
Run `go test ./config/... -count=1` — all tests must PASS (green).
**REFACTOR:** Review for clarity. Run `go vet ./config/...` clean.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./config/... -count=1 -v</automated>
</verify>
<acceptance_criteria>
- config/config.go contains `func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)`
- config/config.go contains `type SoundOverride struct` with `Frequency *float64` and `Waveform *string`
- config/config.go contains `type rawConfig struct` with `Sounds map[string]SoundOverride`
- config/config.go contains `toml.DecodeFile` call
- config/config.go contains `md.Undecoded()` call
- config/config.go contains `validWaveforms` map with 4 entries
- config/config.go contains `os.UserConfigDir()` call in discoverPath
- config/config_test.go contains at least 9 test functions (TestLoad*)
- go.mod contains `github.com/BurntSushi/toml`
- `go test ./config/... -count=1` exits 0
- `go vet ./config/...` exits 0
</acceptance_criteria>
<done>
config package exists with Load function that handles TOML parsing, unknown-key detection, partial-merge, auto-discovery, and waveform validation. All 9+ tests pass. BurntSushi/toml dependency in go.mod.
</done>
</task>
</tasks>
<verification>
- `go test ./config/... -count=1 -v` — all tests pass
- `go vet ./config/...` — no issues
- `go build ./config/...` — compiles cleanly
</verification>
<success_criteria>
- config.Load("path/to/valid.toml") returns merged map with overrides applied (CFG-01)
- config.Load("") with no config file returns defaults silently (CFG-02)
- config.Load("") with partial TOML returns map where unset fields retain defaults (CFG-04)
- config.Load("path/to/typo.toml") with unknown key returns error naming the key (CFG-05)
- All 14 default classes always present in result map
</success_criteria>
<output>
After completion, create `.planning/phases/06-config-package-and-sound-overrides/06-01-SUMMARY.md`
</output>
@@ -0,0 +1,130 @@
---
phase: 06-config-package-and-sound-overrides
plan: 01
subsystem: config
tags: [toml, BurntSushi/toml, config-loading, partial-merge, validation]
# Dependency graph
requires:
- phase: 05-waveform-types-and-bank-decoupling
provides: WaveformType enum, WaveformPresetHarmonics, FreqConfig.WaveformType field
- phase: 01-capture-and-classification
provides: classify.TrafficClass, classify.AllClasses, 14 class constants
provides:
- config.Load(configPath string) returns map[classify.TrafficClass]synth.FreqConfig
- SoundOverride struct with pointer fields for partial-merge semantics
- TOML file parsing with unknown-key detection via BurntSushi/toml Undecoded()
- Auto-discovery of ./netsynth.toml and ~/.config/netsynth/config.toml
- Per-field overlay merge preserving unspecified defaults
- Waveform string validation before merge (fail fast)
affects:
- 06-02 (CLI flag wiring: --config flag passes configPath to config.Load)
- encode package (RunSynthesis will accept merged config map from config.Load)
# Tech tracking
tech-stack:
added: ["github.com/BurntSushi/toml v1.6.0 — TOML parsing with MetaData.Undecoded() for unknown-key detection"]
patterns:
- "Pointer fields (*float64, *string) in decode struct for partial-override semantics (nil = not set)"
- "parseFile → validate → merge pipeline for fail-fast config loading (D-11)"
- "Dedicated config package for testable isolation from Cobra/CLI concerns"
key-files:
created:
- config/config.go
- config/config_test.go
modified:
- go.mod
- go.sum
key-decisions:
- "Used BurntSushi/toml v1.6.0 over pelletier/go-toml v2 — Undecoded() returns structured []Key (not formatted string), easier to extract key name for error messages"
- "Shallow copy in copyDefaults() is safe because merge reconstructs Harmonics via WaveformPresetHarmonics rather than mutating the original slice"
- "Unknown class names produce stderr warning (not error) per D-09, preparing for Phase 7 user-defined classes"
patterns-established:
- "Config package is independent of cmd/ — no Cobra imports, fully unit-testable"
- "Merge functions take defaults map by value and modify in place, returning it"
requirements-completed: [CFG-01, CFG-02, CFG-04, CFG-05]
# Metrics
duration: 3min
completed: 2026-03-26
---
# Phase 6 Plan 01: Config Package Summary
**TOML-based config loader with pointer-field partial merge, BurntSushi/toml Undecoded() unknown-key detection, and XDG auto-discovery at ./netsynth.toml and ~/.config/netsynth/config.toml**
## Performance
- **Duration:** ~3 min
- **Started:** 2026-03-26T19:55:08Z
- **Completed:** 2026-03-26T19:57:45Z
- **Tasks:** 1 (TDD: red → green)
- **Files modified:** 4 (config/config.go, config/config_test.go, go.mod, go.sum)
## Accomplishments
- Created `config` package with single `Load(configPath string)` public API
- Implemented pointer-field partial merge: only non-nil fields override defaults (CFG-04)
- Added BurntSushi/toml Undecoded() for field-level typo detection (CFG-05)
- Auto-discovery of netsynth.toml in working dir and ~/.config/netsynth/config.toml (CFG-02)
- Explicit file missing returns clear error; auto-discovery missing is silent (CFG-02/CFG-03)
- Harmonics regenerated via WaveformPresetHarmonics when waveform or frequency is overridden
- All 9 tests pass; go vet clean
## Task Commits
Each task committed atomically via TDD:
1. **RED - Failing tests** - `b9ec05a` (test): 9 test functions for CFG-01 through CFG-05
2. **GREEN - Full implementation** - `1f877e7` (feat): config.Load, merge, validate, discover
_Note: TDD task has two commits (RED test stub → GREEN implementation)_
## Files Created/Modified
- `config/config.go` - Load(), SoundOverride, rawConfig, merge, validate, discoverPath
- `config/config_test.go` - 9 test functions covering all CFG requirements
- `go.mod` - Added github.com/BurntSushi/toml v1.6.0
- `go.sum` - Updated checksum for new dependency
## Decisions Made
- **BurntSushi/toml over pelletier/go-toml v2**: Undecoded() returns `[]toml.Key` ([]string slices) — structured, allowing exact key name extraction for error messages. pelletier's DisallowUnknownFields returns a formatted string (harder to extract just the key name).
- **Shallow copy in copyDefaults()**: Safe because merge code always replaces `Harmonics` with a freshly generated slice from WaveformPresetHarmonics rather than mutating the original. Documented with comment for future maintainers.
- **Unknown class warning (not error)**: Following D-09 to emit `fmt.Fprintf(os.Stderr, "Warning: ...")` for unknown class names. Phase 7 user-defined classes will be valid, so this is by design.
## Deviations from Plan
None - plan executed exactly as written. The worktree needed a rebase onto master to include phase 05 code (WaveformType, WaveformPresetHarmonics) before starting — this was a prerequisite resolution, not a deviation.
## Issues Encountered
- Worktree was based on remote origin/master (pre-phase-05). Rebased onto local master to get WaveformType and WaveformPresetHarmonics before implementation. No code conflicts.
## User Setup Required
None - no external service configuration required. BurntSushi/toml is fetched automatically via `go get`.
## Next Phase Readiness
- `config.Load()` is ready for wiring into `cmd/netsynth/main.go` via `--config` flag (Plan 06-02)
- `encode.RunSynthesis` signature change (accept `freqCfgs map[classify.TrafficClass]synth.FreqConfig`) is needed in Plan 06-02
- All 14 default classes always present in result map — safe to pass directly to `synth.NewBank()`
## Self-Check: PASSED
- FOUND: config/config.go
- FOUND: config/config_test.go
- FOUND: .planning/phases/06-config-package-and-sound-overrides/06-01-SUMMARY.md
- FOUND: b9ec05a (RED commit — failing tests)
- FOUND: 1f877e7 (GREEN commit — full implementation)
---
*Phase: 06-config-package-and-sound-overrides*
*Completed: 2026-03-26*
@@ -0,0 +1,262 @@
---
phase: 06-config-package-and-sound-overrides
plan: 02
type: execute
wave: 2
depends_on: ["06-01"]
files_modified:
- encode/mp3.go
- encode/mp3_test.go
- cmd/netsynth/main.go
autonomous: true
requirements:
- CFG-03
must_haves:
truths:
- "User passes --config /path/to/file.toml and the tool uses that file for sound overrides"
- "User passes --config /nonexistent.toml and the tool exits with a clear error before capture"
- "User runs without --config and auto-discovery kicks in (or defaults used silently)"
- "RunSynthesis uses the merged config map instead of hardcoded ClassFreqConfigs"
artifacts:
- path: "cmd/netsynth/main.go"
provides: "--config flag, config.Load call, passing merged map to RunSynthesis"
contains: "configPath"
- path: "encode/mp3.go"
provides: "RunSynthesis with freqCfgs parameter"
contains: "freqCfgs map[classify.TrafficClass]synth.FreqConfig"
- path: "encode/mp3_test.go"
provides: "Updated tests for new RunSynthesis signature"
key_links:
- from: "cmd/netsynth/main.go"
to: "config/config.go"
via: "config.Load(configPath)"
pattern: "config\\.Load"
- from: "cmd/netsynth/main.go"
to: "encode/mp3.go"
via: "encode.RunSynthesis(snapshots, outputPath, freqCfgs)"
pattern: "encode\\.RunSynthesis.*freqCfgs"
- from: "encode/mp3.go"
to: "synth/bank.go"
via: "synth.NewBank(1.0, freqCfgs) using passed-in config"
pattern: "synth\\.NewBank.*freqCfgs"
---
<objective>
Wire the config package into the CLI and synthesis pipeline. Add `--config` flag to Cobra, call `config.Load` at startup, change `RunSynthesis` signature to accept the merged config map, and update all call sites.
Purpose: Completes CFG-03 (explicit --config flag) and D-10 (RunSynthesis signature change). After this plan, the end-to-end flow works: user creates TOML -> tool loads it -> synthesis uses overridden frequencies/waveforms.
Output: Updated `cmd/netsynth/main.go`, `encode/mp3.go`, `encode/mp3_test.go`
</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/06-config-package-and-sound-overrides/06-CONTEXT.md
@.planning/phases/06-config-package-and-sound-overrides/06-01-SUMMARY.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From config/config.go (created in Plan 01):
```go
// Load finds, parses, validates, and merges a config file.
// configPath is the --config flag value; empty string triggers auto-discovery.
func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)
```
From encode/mp3.go (current signature to change):
```go
// Current:
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error
// New:
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error
```
From cmd/netsynth/main.go (existing flags pattern):
```go
var (
ifaceName string
listIfaces bool
verbose bool
outputPath string
bpfFilter string
readPath string
)
// Flag registration pattern:
rootCmd.Flags().StringVar(&bpfFilter, "filter", "", "BPF filter expression")
```
From synth/config.go:
```go
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ /* 14 entries */ }
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Change RunSynthesis signature and update encode tests</name>
<files>encode/mp3.go, encode/mp3_test.go</files>
<read_first>
encode/mp3.go (current RunSynthesis signature and body)
encode/mp3_test.go (current test calls to RunSynthesis)
synth/config.go (ClassFreqConfigs, FreqConfig type)
classify/types.go (TrafficClass type)
</read_first>
<action>
**encode/mp3.go changes:**
1. Add `freqCfgs map[classify.TrafficClass]synth.FreqConfig` as third parameter to RunSynthesis:
```
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error
```
2. Change line 57 from:
```
bank := synth.NewBank(1.0, synth.ClassFreqConfigs)
```
to:
```
bank := synth.NewBank(1.0, freqCfgs)
```
3. No other changes to encode/mp3.go.
**encode/mp3_test.go changes:**
4. Update `TestMP3Valid` (line 56): change `RunSynthesis(snaps, tmpPath)` to `RunSynthesis(snaps, tmpPath, synth.ClassFreqConfigs)`.
5. Update `TestZeroPacketError` (line 101): change `RunSynthesis([]classify.WindowSnapshot{}, tmpPath)` to `RunSynthesis([]classify.WindowSnapshot{}, tmpPath, synth.ClassFreqConfigs)`.
6. Update `TestZeroPacketError` (line 121): change `RunSynthesis(zeroSnaps, tmpPath2)` to `RunSynthesis(zeroSnaps, tmpPath2, synth.ClassFreqConfigs)`.
The `synth` import is already present in mp3_test.go.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go build ./encode/... && go test ./encode/... -count=1 -run TestZeroPacketError</automated>
</verify>
<acceptance_criteria>
- encode/mp3.go contains `func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error`
- encode/mp3.go contains `synth.NewBank(1.0, freqCfgs)` (not synth.ClassFreqConfigs)
- encode/mp3_test.go contains `RunSynthesis(snaps, tmpPath, synth.ClassFreqConfigs)`
- encode/mp3_test.go contains `RunSynthesis([]classify.WindowSnapshot{}, tmpPath, synth.ClassFreqConfigs)`
- encode/mp3_test.go contains `RunSynthesis(zeroSnaps, tmpPath2, synth.ClassFreqConfigs)`
- `go build ./encode/...` exits 0
- `go test ./encode/... -count=1 -run TestZeroPacketError` exits 0
</acceptance_criteria>
<done>
RunSynthesis accepts injected config map. All encode tests updated and passing.
</done>
</task>
<task type="auto">
<name>Task 2: Add --config flag and wire config.Load into main.go</name>
<files>cmd/netsynth/main.go</files>
<read_first>
cmd/netsynth/main.go (full file — flag definitions, run function, runLiveMode, runPcapMode)
config/config.go (Load function signature)
encode/mp3.go (updated RunSynthesis signature from Task 1)
</read_first>
<action>
**cmd/netsynth/main.go changes:**
1. Add `configPath` to the var block (after `readPath`):
```go
configPath string // NEW: --config flag (CFG-03)
```
2. Add import for config package in the import block:
```go
"github.com/netsynth/netsynth/config"
```
Also add import for `synth` package (needed for ClassFreqConfigs fallback reference — though config.Load handles this internally):
No — `synth` is NOT needed in main.go. `config.Load` returns the full map. Only add `config` import.
3. Add flag registration in main() after the `readPath` flag line (line 44):
```go
rootCmd.Flags().StringVar(&configPath, "config", "", "Path to TOML config file (default: auto-discover)")
```
4. In the `run` function, add config loading AFTER the BPF filter validation block (after line 80) and BEFORE output path resolution (before line 83). This is per D-11 (fail fast on config errors before capture):
```go
// Load config (CFG-01 through CFG-05, D-11: fail fast)
freqCfgs, err := config.Load(configPath)
if err != nil {
return err
}
```
5. The `freqCfgs` variable must be accessible in both `runLiveMode` and `runPcapMode`. Two approaches:
- Option A: Pass freqCfgs to both functions (cleanest).
- Option B: Store in a package-level var (simpler change).
Use Option A. Change signatures:
- `runLiveMode(cmd *cobra.Command) error` -> `runLiveMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error`
- `runPcapMode(cmd *cobra.Command) error` -> `runPcapMode(cmd *cobra.Command, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error`
This requires adding `synth` import after all:
```go
"github.com/netsynth/netsynth/synth"
```
Update call sites in `run()`:
- Line 92: `return runPcapMode(cmd)` -> `return runPcapMode(cmd, freqCfgs)`
- Line 94: `return runLiveMode(cmd)` -> `return runLiveMode(cmd, freqCfgs)`
6. In `runLiveMode`, change the RunSynthesis call (line 147):
From: `encode.RunSynthesis(collectedSnapshots, outputPath)`
To: `encode.RunSynthesis(collectedSnapshots, outputPath, freqCfgs)`
7. In `runPcapMode`, change the RunSynthesis call (line 215):
From: `encode.RunSynthesis(collectedSnapshots, outputPath)`
To: `encode.RunSynthesis(collectedSnapshots, outputPath, freqCfgs)`
After all changes, run `go build ./cmd/netsynth/...` to verify compilation.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go build ./cmd/netsynth/... && go vet ./cmd/netsynth/... && go test ./... -count=1 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- cmd/netsynth/main.go contains `configPath string`
- cmd/netsynth/main.go contains `rootCmd.Flags().StringVar(&configPath, "config", ""`
- cmd/netsynth/main.go contains `config.Load(configPath)`
- cmd/netsynth/main.go contains `"github.com/netsynth/netsynth/config"` in imports
- cmd/netsynth/main.go contains `encode.RunSynthesis(collectedSnapshots, outputPath, freqCfgs)` (two occurrences — one in runLiveMode, one in runPcapMode)
- cmd/netsynth/main.go contains `runLiveMode(cmd, freqCfgs)` and `runPcapMode(cmd, freqCfgs)`
- `go build ./cmd/netsynth/...` exits 0
- `go vet ./cmd/netsynth/...` exits 0
- `go test ./... -count=1` exits 0 (full suite green)
</acceptance_criteria>
<done>
--config flag registered in Cobra. config.Load called at startup before capture. Merged config map flows through to RunSynthesis in both live and pcap modes. Full test suite passes.
</done>
</task>
</tasks>
<verification>
- `go build ./...` compiles entire project
- `go test ./... -count=1` all tests pass
- `go vet ./...` no issues
- `./netsynth --help` shows `--config` flag in output
</verification>
<success_criteria>
- --config flag appears in CLI help output (CFG-03)
- Explicit --config with missing file produces error before capture (CFG-03)
- RunSynthesis uses injected config map, not hardcoded ClassFreqConfigs (D-10)
- Full test suite passes including encode and config package tests
</success_criteria>
<output>
After completion, create `.planning/phases/06-config-package-and-sound-overrides/06-02-SUMMARY.md`
</output>
@@ -0,0 +1,119 @@
---
phase: 06-config-package-and-sound-overrides
plan: 02
subsystem: cmd/encode
tags: [cli, config, RunSynthesis, dependency-injection, cobra]
# Dependency graph
requires:
- phase: 06-01
provides: config.Load(configPath string) returns map[classify.TrafficClass]synth.FreqConfig
- phase: 05-waveform-types-and-bank-decoupling
provides: NewBank(tau, cfgs) with injected config map, FreqConfig.WaveformType
provides:
- --config flag in CLI (CFG-03)
- config.Load called at startup before capture (D-11 fail fast)
- RunSynthesis(snapshots, outputPath, freqCfgs) with injected config map (D-10)
- Merged config flows end-to-end: TOML file -> config.Load -> RunSynthesis -> NewBank
affects:
- encode/mp3.go (RunSynthesis signature changed)
- cmd/netsynth/main.go (--config flag, config.Load, pass freqCfgs through pipeline)
# Tech tracking
tech-stack:
added: []
patterns:
- "Dependency injection: config map flows from main() through runLiveMode/runPcapMode to RunSynthesis to NewBank"
- "Fail-fast config loading: config.Load called after BPF validation, before capture starts (D-11)"
- "Explicit configPath string var for --config flag, empty string triggers auto-discovery"
key-files:
created: []
modified:
- encode/mp3.go
- encode/mp3_test.go
- cmd/netsynth/main.go
key-decisions:
- "Option A for freqCfgs propagation: pass as parameter to runLiveMode/runPcapMode rather than package-level var — explicit data flow, easier to test"
- "config.Load called before output path resolution — config errors abort before any state changes"
patterns-established:
- "Config map injected at call boundary (main -> run -> runLiveMode/runPcapMode -> RunSynthesis -> NewBank)"
requirements-completed: [CFG-03]
# Metrics
duration: 2min
completed: 2026-03-26
---
# Phase 6 Plan 02: CLI Config Wiring Summary
**--config flag added to Cobra, config.Load wired at startup, RunSynthesis signature changed to accept injected freqCfgs map — end-to-end config flow from TOML file to synthesis**
## Performance
- **Duration:** ~2 min
- **Started:** 2026-03-26T20:01:59Z
- **Completed:** 2026-03-26T20:04:27Z
- **Tasks:** 2
- **Files modified:** 3 (encode/mp3.go, encode/mp3_test.go, cmd/netsynth/main.go)
## Accomplishments
- Changed `RunSynthesis` third parameter: accepts `freqCfgs map[classify.TrafficClass]synth.FreqConfig` (D-10)
- Updated all 3 RunSynthesis call sites in encode tests to pass `synth.ClassFreqConfigs`
- Added `configPath string` var and `--config` flag registration in Cobra (CFG-03)
- Imported `config` and `synth` packages into cmd/netsynth/main.go
- Wired `config.Load(configPath)` into `run()` after BPF validation, before capture (D-11)
- Changed `runLiveMode` and `runPcapMode` signatures to accept `freqCfgs` parameter
- Updated both `encode.RunSynthesis` call sites to pass `freqCfgs`
- Full test suite passes: 7 packages, all green
## Task Commits
1. **Task 1** - `3dfcbbe` feat(06-02): add freqCfgs parameter to RunSynthesis
2. **Task 2** - `413cceb` feat(06-02): wire --config flag and config.Load into CLI pipeline
## Files Created/Modified
- `encode/mp3.go` - RunSynthesis now accepts `freqCfgs map[classify.TrafficClass]synth.FreqConfig`; uses `freqCfgs` in `synth.NewBank(1.0, freqCfgs)` call
- `encode/mp3_test.go` - Updated 3 RunSynthesis calls to pass `synth.ClassFreqConfigs` as third arg
- `cmd/netsynth/main.go` - `configPath` var, `--config` flag, `config` and `synth` imports, `config.Load` call, updated function signatures, updated RunSynthesis calls
## Decisions Made
- **Option A for freqCfgs propagation**: Pass config map as function parameter to `runLiveMode`/`runPcapMode` rather than storing in a package-level variable. Cleaner data flow, functions remain testable in isolation.
- **config.Load position in run()**: Called after BPF filter validation, before output path resolution and capture start. Config errors abort immediately before any I/O begins (D-11).
## Deviations from Plan
None - plan executed exactly as written. The worktree required a rebase onto local master to include phase 05 bank-decoupling code (NewBank 2-arg signature) and phase 06-01 config package before implementation could begin — this is expected prerequisite resolution, not a deviation.
## Issues Encountered
- Worktree was based on origin/master (commit 41e2278, pre-phase-05). Rebased onto local master (936aeea) to get WaveformType, 2-arg NewBank, and config package. No code conflicts.
## User Setup Required
None.
## Next Phase Readiness
- Full end-to-end config flow is wired: user creates netsynth.toml -> `--config` passes path -> `config.Load` merges -> `RunSynthesis` uses merged map -> `NewBank` synthesizes with custom frequencies/waveforms
- Phase 06-03 (if any) can build on this wired pipeline for additional config features
## Self-Check: PASSED
- FOUND: encode/mp3.go — contains `func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error`
- FOUND: encode/mp3_test.go — contains `RunSynthesis(snaps, tmpPath, synth.ClassFreqConfigs)`
- FOUND: cmd/netsynth/main.go — contains `configPath string`, `config.Load(configPath)`, `runLiveMode(cmd, freqCfgs)`
- FOUND: 3dfcbbe (Task 1 commit)
- FOUND: 413cceb (Task 2 commit)
---
*Phase: 06-config-package-and-sound-overrides*
*Completed: 2026-03-26*
@@ -0,0 +1,111 @@
# Phase 6: Config Package and Sound Overrides - Context
**Gathered:** 2026-03-26
**Status:** Ready for planning
<domain>
## Phase Boundary
Add a TOML-based configuration system that lets users override frequency and waveform per traffic class, with auto-discovery from standard paths, explicit `--config` flag, partial override semantics (only specified fields change), and strict unknown-key validation. Wire the merged config into the synthesis pipeline via the injection seam created in Phase 5.
Requirements covered: CFG-01 through CFG-05.
</domain>
<decisions>
## Implementation Decisions
### TOML Schema Design
- **D-01:** Use keyed TOML tables `[sounds.<classname>]` for per-class overrides. Each table supports `frequency` (float64, Hz) and `waveform` (string: "sine", "square", "sawtooth", "triangle"). Class names match `classify.TrafficClass` string values (e.g., `[sounds.ICMP]`, `[sounds.HTTPS]`).
- **D-02:** Top-level structure is flat — no deeply nested hierarchies. Future phases (custom rules) will add `[[rules]]` array-of-tables at the top level.
### Config Merge Semantics
- **D-03:** Per-field overlay merge — only fields explicitly set in TOML override defaults. Unspecified fields retain their built-in values. For example, setting only `frequency` for ICMP leaves its waveform and harmonics unchanged. This satisfies CFG-04 (partial override without replicating entire config).
- **D-04:** Merge produces a `map[classify.TrafficClass]FreqConfig` that is passed to `synth.NewBank()` via the injection seam from Phase 5. The default map is `synth.ClassFreqConfigs`.
### Auto-Discovery and Precedence
- **D-05:** Discovery order (most-specific wins): `--config <path>` > `./netsynth.toml` > `~/.config/netsynth/config.toml`. If `--config` is specified and the file does not exist, exit with a clear error before capture begins (CFG-03). If no config is found via auto-discovery, proceed silently with defaults (CFG-02).
- **D-06:** Only one config file is loaded — no multi-file merge. The first found in precedence order wins entirely.
### Validation and Error Reporting
- **D-07:** Unknown keys cause an immediate startup error naming the unrecognized key (CFG-05). Use TOML strict decoding to detect unknown keys. Suggest the closest valid key name if edit distance is small (nice-to-have, Claude's discretion on implementation).
- **D-08:** Type mismatches (e.g., `frequency = "not a number"`) produce a clear error with field name and expected type, before capture begins.
- **D-09:** Unknown class names in `[sounds.<classname>]` produce a warning (not error) — this prepares for Phase 7 where user-defined class names are valid.
### Pipeline Wiring
- **D-10:** `encode.RunSynthesis` signature changes to accept the merged config map (or loads config internally). The `--config` flag is added to the Cobra root command in `cmd/netsynth/main.go`.
- **D-11:** Config loading happens once at startup, before any capture begins — fail fast on all config errors.
### Claude's Discretion
- TOML library choice (BurntSushi/toml vs pelletier/go-toml) — researcher should evaluate both
- Whether to create a dedicated `config` package or keep loading in `cmd/netsynth`
- Waveform string-to-WaveformType mapping implementation details
- Edit distance algorithm for typo suggestions (or skip if complexity isn't justified)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Injection Seam (Phase 5 output)
- `synth/bank.go``NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig)` — the injection point for merged config
- `synth/config.go``ClassFreqConfigs` default map, `FreqConfig` struct with `WaveformType` field, `WaveformPresetHarmonics()` function
- `encode/mp3.go``RunSynthesis()` calls `synth.NewBank(1.0, synth.ClassFreqConfigs)` — the call site to modify
### CLI Entry Point
- `cmd/netsynth/main.go` — Cobra command setup, flag definitions, `run()` function that dispatches to live/pcap modes
### Requirements
- `.planning/REQUIREMENTS.md` — CFG-01 through CFG-05 acceptance criteria
### Prior Context
- `.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md` — Phase 5 decisions (D-02 WaveformType, D-05 bank injection seam)
No external specs — requirements fully captured in decisions above.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `synth.ClassFreqConfigs` — Default config map (14 entries), serves as base for merge
- `synth.WaveformType` enum — Maps to TOML waveform strings (sine/square/sawtooth/triangle)
- `synth.NewBank(tau, cfgs)` — Already accepts injected config map (Phase 5)
- `classify.TrafficClass` (string type) — Keys for config map, matches TOML section names
- `classify.AllClasses()` — Returns all 14 built-in class names for validation
### Established Patterns
- Cobra for CLI flags — add `--config` flag in same pattern as existing flags
- `encode.RunSynthesis` is the single call site for synthesis — modification point is narrow
- Package-level vars (`ClassFreqConfigs`, `DefaultRules`) serve as defaults — config system overlays on top
### Integration Points
- `cmd/netsynth/main.go:run()` — Config loading inserts between flag parsing and capture start
- `encode.RunSynthesis()` — Must receive merged config map (currently hardcoded to `synth.ClassFreqConfigs`)
- `synth.FreqConfig.WaveformType` field — Set from TOML waveform string after parsing
</code_context>
<specifics>
## Specific Ideas
No specific requirements — standard TOML config pattern with partial merge semantics.
</specifics>
<deferred>
## Deferred Ideas
- `--print-config` command (CFG-06) — scoped to Phase 7
- Custom classification rules (`[[rules]]` TOML blocks) — scoped to Phase 7
- Config hot-reload — explicitly out of scope per REQUIREMENTS.md
</deferred>
---
*Phase: 06-config-package-and-sound-overrides*
*Context gathered: 2026-03-26*
@@ -0,0 +1,75 @@
# Phase 6: Config Package and Sound Overrides - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-03-26
**Phase:** 06-config-package-and-sound-overrides
**Areas discussed:** TOML structure, Config merge, Auto-discovery precedence, Error reporting
**Mode:** --auto (all areas auto-selected, recommended defaults chosen)
---
## TOML Structure
| Option | Description | Selected |
|--------|-------------|----------|
| Keyed table `[sounds.<classname>]` | Natural TOML pattern, matches traffic class names | ✓ |
| Flat key-value pairs | Simpler but doesn't scale to per-class overrides | |
| Nested `[sounds.<classname>.audio]` | Unnecessary nesting depth | |
**User's choice:** [auto] Keyed table `[sounds.<classname>]` (recommended default)
**Notes:** Matches classify.TrafficClass string values directly. Supports `frequency` and `waveform` fields per class.
---
## Config Merge Semantics
| Option | Description | Selected |
|--------|-------------|----------|
| Per-field overlay | Only specified fields override defaults (CFG-04) | ✓ |
| Full section replace | Setting any field in a class replaces all fields | |
| Deep merge with arrays | Overkill for flat config structure | |
**User's choice:** [auto] Per-field overlay (recommended default)
**Notes:** Satisfies CFG-04 requirement. User sets one field, everything else keeps defaults.
---
## Auto-Discovery Precedence
| Option | Description | Selected |
|--------|-------------|----------|
| Local > user > flag | Most-specific wins: --config > ./netsynth.toml > ~/.config/ | ✓ |
| Flag only | Simpler but no auto-discovery (violates CFG-02) | |
| Multi-file merge | Load and merge all found configs | |
**User's choice:** [auto] Local > user-level > flag (recommended default)
**Notes:** Standard CLI convention. Only one file loaded — no multi-file merge complexity.
---
## Error Reporting
| Option | Description | Selected |
|--------|-------------|----------|
| Fail-fast with key name + suggestion | Exit at startup, name the bad key (CFG-05) | ✓ |
| Warning and continue | Tolerant but hides mistakes | |
| Strict with no suggestions | Simpler but less helpful | |
**User's choice:** [auto] Fail-fast with key name and optional typo suggestion (recommended default)
**Notes:** Matches CFG-05 requirement. Unknown class names are warnings (not errors) to prepare for Phase 7.
---
## Claude's Discretion
- TOML library choice
- Package organization (dedicated `config` package vs inline)
- Waveform string mapping implementation
- Edit distance for typo suggestions
## Deferred Ideas
- `--print-config` (CFG-06) — Phase 7
- Custom rules `[[rules]]` — Phase 7
@@ -0,0 +1,590 @@
# Phase 6: Config Package and Sound Overrides - Research
**Researched:** 2026-03-26
**Domain:** Go TOML config loading, partial merge semantics, CLI flag wiring
**Confidence:** HIGH
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Use keyed TOML tables `[sounds.<classname>]` for per-class overrides. Each table supports `frequency` (float64, Hz) and `waveform` (string: "sine", "square", "sawtooth", "triangle"). Class names match `classify.TrafficClass` string values (e.g., `[sounds.ICMP]`, `[sounds.HTTPS]`).
- **D-02:** Top-level structure is flat — no deeply nested hierarchies. Future phases (custom rules) will add `[[rules]]` array-of-tables at the top level.
- **D-03:** Per-field overlay merge — only fields explicitly set in TOML override defaults. Unspecified fields retain their built-in values. For example, setting only `frequency` for ICMP leaves its waveform and harmonics unchanged. This satisfies CFG-04 (partial override without replicating entire config).
- **D-04:** Merge produces a `map[classify.TrafficClass]FreqConfig` that is passed to `synth.NewBank()` via the injection seam from Phase 5. The default map is `synth.ClassFreqConfigs`.
- **D-05:** Discovery order (most-specific wins): `--config <path>` > `./netsynth.toml` > `~/.config/netsynth/config.toml`. If `--config` is specified and the file does not exist, exit with a clear error before capture begins (CFG-03). If no config is found via auto-discovery, proceed silently with defaults (CFG-02).
- **D-06:** Only one config file is loaded — no multi-file merge. The first found in precedence order wins entirely.
- **D-07:** Unknown keys cause an immediate startup error naming the unrecognized key (CFG-05). Use TOML strict decoding to detect unknown keys. Suggest the closest valid key name if edit distance is small (nice-to-have, Claude's discretion on implementation).
- **D-08:** Type mismatches (e.g., `frequency = "not a number"`) produce a clear error with field name and expected type, before capture begins.
- **D-09:** Unknown class names in `[sounds.<classname>]` produce a warning (not error) — this prepares for Phase 7 where user-defined class names are valid.
- **D-10:** `encode.RunSynthesis` signature changes to accept the merged config map (or loads config internally). The `--config` flag is added to the Cobra root command in `cmd/netsynth/main.go`.
- **D-11:** Config loading happens once at startup, before any capture begins — fail fast on all config errors.
### Claude's Discretion
- TOML library choice (BurntSushi/toml vs pelletier/go-toml) — researcher should evaluate both
- Whether to create a dedicated `config` package or keep loading in `cmd/netsynth`
- Waveform string-to-WaveformType mapping implementation details
- Edit distance algorithm for typo suggestions (or skip if complexity isn't justified)
### Deferred Ideas (OUT OF SCOPE)
- `--print-config` command (CFG-06) — scoped to Phase 7
- Custom classification rules (`[[rules]]` TOML blocks) — scoped to Phase 7
- Config hot-reload — explicitly out of scope per REQUIREMENTS.md
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| CFG-01 | User can create a TOML config file that overrides default sound mappings | `[sounds.<classname>]` table pattern decodes into `map[string]SoundOverride`; per-field merge into `synth.ClassFreqConfigs` clone |
| CFG-02 | Tool auto-discovers config from `./netsynth.toml` or `~/.config/netsynth/config.toml` (silent if absent) | `os.Stat` probe + `os.UserConfigDir()` for XDG path; `errors.Is(err, fs.ErrNotExist)` for silent miss |
| CFG-03 | User can specify an explicit config path via `--config` flag (error if file missing) | Cobra `StringVar` flag; fail-fast `os.Stat` check returns error before capture begins |
| CFG-04 | User can override individual values without replicating the entire default config (partial override) | Pointer fields (`*float64`, `*string`) in the TOML decode struct allow distinguishing "explicitly zero" from "not set"; overlay merge copies only non-nil fields |
| CFG-05 | Unknown keys in config file produce a clear error with the typo'd key name | BurntSushi/toml `MetaData.Undecoded()` returns unmatched keys after decode; format as error message |
</phase_requirements>
---
## Summary
Phase 6 adds a `config` package responsible for loading a TOML config file, validating it, and merging it over the `synth.ClassFreqConfigs` default map. The merge output is a `map[classify.TrafficClass]synth.FreqConfig` that is handed to `synth.NewBank()` — the injection seam already exists from Phase 5.
The core technical challenge is **partial override semantics**: a user who sets only `frequency` for ICMP must not accidentally clear its waveform. This requires the decode struct to use pointer fields (`*float64`, `*string`) so that absent keys remain `nil` at decode time. The merge loop then only copies non-nil values over the defaults.
Unknown-key detection uses BurntSushi/toml v1.6.0's `MetaData.Undecoded()` method, which is reliable because it operates on the actual set of keys the parser traversed. The alternative (pelletier/go-toml v2.3.0's `DisallowUnknownFields`) is also viable but adds a dependency with a different API surface and returns human-formatted error strings rather than structured key lists — less useful for the "suggest closest valid key" nice-to-have.
**Primary recommendation:** Use `github.com/BurntSushi/toml` v1.6.0. Use a dedicated `config` package. Implement partial merge with pointer fields in the TOML decode struct.
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `github.com/BurntSushi/toml` | v1.6.0 | TOML parsing and MetaData for unknown-key detection | Simpler API than pelletier v2; `Undecoded()` returns structured `[]Key` (not formatted error strings); `DecodeFile()` is a one-liner; v1.6.0 published December 2025 |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `os` (stdlib) | Go 1.24 | File existence checks, `UserConfigDir()` for XDG path | Always — no external dependency needed for discovery logic |
| `errors`/`fs` (stdlib) | Go 1.24 | `errors.Is(err, fs.ErrNotExist)` for silent-miss on auto-discovery | Always |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `BurntSushi/toml v1.6.0` | `pelletier/go-toml v2.3.0` | go-toml has `DisallowUnknownFields()` built-in (cleaner API) but returns `StrictMissingError` with a formatted string — harder to extract just the key name for a "did you mean?" suggestion. BurntSushi returns `[]toml.Key` which is structured. For this use case, BurntSushi is easier to work with. |
| Pointer fields for partial override | Separate "is-set" booleans | Pointer fields are idiomatic in Go for "optional" semantics. Booleans add field count and are error-prone. |
| Dedicated `config` package | Inline in `cmd/netsynth` | A `config` package makes the loader independently testable without a Cobra dependency. Given the complexity (validation, merge, discovery), a separate package is justified. |
**Installation:**
```bash
go get github.com/BurntSushi/toml@v1.6.0
```
**Version verification (confirmed 2026-03-26):**
```
github.com/BurntSushi/toml v1.6.0 (December 18, 2025)
github.com/pelletier/go-toml/v2 v2.3.0 (March 24, 2026 — alternative)
```
## Architecture Patterns
### Recommended Project Structure
```
config/
├── config.go # Load(), Merge(), Validate() — public API
└── config_test.go # table-driven tests for all CFG requirements
```
The `config` package has one exported function signature the planner cares about:
```go
// Load finds, parses, validates, and merges a config file.
// configPath is the --config flag value; empty string triggers auto-discovery.
// Returns the merged FreqConfig map (defaults + overrides) ready for synth.NewBank.
// Returns an error on: file-not-found when --config is explicit, parse errors,
// unknown keys, type mismatches. Returns no error (uses defaults) when no config
// is found during auto-discovery.
func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)
```
### Pattern 1: TOML Decode Struct with Pointer Fields
**What:** The TOML config file maps to a Go struct where every overridable field is a pointer. `nil` means "not set by user"; non-nil means "user explicitly specified this value."
**When to use:** Whenever you need to distinguish "field absent from config" from "field set to zero value" — mandatory for partial override semantics (CFG-04).
```go
// Source: BurntSushi/toml documentation + partial-override pattern
// config/config.go
// SoundOverride holds optional per-class sound parameters from TOML.
// Pointer fields: nil = not set (keep default), non-nil = user override.
type SoundOverride struct {
Frequency *float64 `toml:"frequency"`
Waveform *string `toml:"waveform"`
}
// rawConfig is the top-level TOML decode target.
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
}
```
### Pattern 2: Unknown-Key Detection with MetaData.Undecoded()
**What:** After decoding, check `md.Undecoded()` for any keys in the TOML file that did not map to a field in the decode struct. Return an error naming the first unrecognized key.
**When to use:** Required for CFG-05. Also the mechanism to detect field-level typos within a `[sounds.ICMP]` block (e.g., `frequncy` vs `frequency`).
```go
// Source: pkg.go.dev/github.com/BurntSushi/toml
// config/config.go
func parse(path string) (rawConfig, error) {
var raw rawConfig
md, err := toml.DecodeFile(path, &raw)
if err != nil {
return raw, fmt.Errorf("config parse error: %w", err)
}
if undecoded := md.Undecoded(); len(undecoded) > 0 {
// undecoded[0] is a toml.Key ([]string); join for human-readable path
keyPath := strings.Join(undecoded[0], ".")
return raw, fmt.Errorf("config: unknown key %q — check spelling", keyPath)
}
return raw, nil
}
```
**IMPORTANT NOTE on nested map + Undecoded():** When the decode struct uses `map[string]SoundOverride` for `[sounds]`, the TOML library cannot know what map keys are "valid" — all string keys are valid map keys. This means `Undecoded()` will NOT catch a misspelled class name like `[sounds.ICMP_typo]` (it IS decoded, just into a wrong map key). However, `Undecoded()` WILL catch field-level typos within a class block like `[sounds.ICMP]` with `frequncy = 440` because `frequncy` doesn't match any `SoundOverride` field. Class-name validation is handled separately in the merge step (D-09: log a warning for unknown class names).
### Pattern 3: Per-Field Overlay Merge
**What:** Iterate over the default `ClassFreqConfigs` map, copy it, then for each entry found in the TOML overrides, copy only the non-nil pointer fields into the working copy.
**When to use:** This is the CFG-04 implementation. Must run after parse and validation.
```go
// config/config.go
func merge(
defaults map[classify.TrafficClass]synth.FreqConfig,
overrides map[string]SoundOverride,
) map[classify.TrafficClass]synth.FreqConfig {
// Deep-copy defaults
result := make(map[classify.TrafficClass]synth.FreqConfig, len(defaults))
for k, v := range defaults {
result[k] = v
}
for className, override := range overrides {
class := classify.TrafficClass(className)
cfg, known := result[class]
if !known {
// D-09: unknown class name = warning, not error (Phase 7 may define it)
fmt.Fprintf(os.Stderr, "Warning: config: unknown class %q (ignored)\n", className)
continue
}
if override.Frequency != nil {
cfg.BaseHz = *override.Frequency
// When frequency changes, regenerate harmonics if a waveform preset is active
if cfg.WaveformType != synth.WaveformCustom {
cfg.Harmonics = synth.WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, synth.SampleRate)
}
}
if override.Waveform != nil {
wt, err := parseWaveform(*override.Waveform)
if err != nil {
// Validation catches this before merge; this is a safety guard
continue
}
cfg.WaveformType = wt
cfg.Harmonics = synth.WaveformPresetHarmonics(wt, cfg.BaseHz, synth.SampleRate)
}
result[class] = cfg
}
return result
}
```
### Pattern 4: Waveform String-to-Type Mapping
**What:** A simple switch converts the TOML `waveform` string to `synth.WaveformType`. Validation happens before merge.
```go
// config/config.go
var validWaveforms = map[string]synth.WaveformType{
"sine": synth.WaveformSine,
"square": synth.WaveformSquare,
"sawtooth": synth.WaveformSawtooth,
"triangle": synth.WaveformTriangle,
}
func parseWaveform(s string) (synth.WaveformType, error) {
if wt, ok := validWaveforms[s]; ok {
return wt, nil
}
valid := []string{"sine", "square", "sawtooth", "triangle"}
return 0, fmt.Errorf("config: invalid waveform %q — valid values: %s", s, strings.Join(valid, ", "))
}
```
### Pattern 5: Auto-Discovery with os.UserConfigDir
**What:** Check paths in precedence order. Return the path of the first file found, or `""` (empty) if none found. Never log anything for a missing auto-discovered file.
```go
// config/config.go
func discoverPath() string {
// 1. Working directory
if _, err := os.Stat("netsynth.toml"); err == nil {
return "netsynth.toml"
}
// 2. XDG config dir
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
p := filepath.Join(dir, "netsynth", "config.toml")
if _, err := os.Stat(p); err == nil {
return p
}
return ""
}
```
`os.UserConfigDir()` returns `$XDG_CONFIG_HOME` or `$HOME/.config` on Linux (Go stdlib, no extra dependency). Confirmed by Go source: returns `$XDG_CONFIG_HOME` if set, else `$HOME/.config` on Unix.
### Pattern 6: encode.RunSynthesis Signature Change
**What:** `RunSynthesis` currently calls `synth.NewBank(1.0, synth.ClassFreqConfigs)` hardcoded. Phase 6 changes the signature to accept the merged config map.
The simplest approach: pass the merged config map as a parameter (rather than loading config inside `encode`). This keeps `encode` unaware of config loading and makes testing easier.
```go
// encode/mp3.go — updated signature
func RunSynthesis(
snapshots []classify.WindowSnapshot,
outputPath string,
freqCfgs map[classify.TrafficClass]synth.FreqConfig,
) error {
// ...
bank := synth.NewBank(1.0, freqCfgs) // was: synth.ClassFreqConfigs
// ...
}
```
Caller in `cmd/netsynth/main.go` passes the result of `config.Load(configPath)`.
### Anti-Patterns to Avoid
- **Decode into `map[string]interface{}`:** Loses type safety, makes unknown-field detection harder, requires runtime type assertions. Use typed structs.
- **Load config inside `encode` package:** Couples audio encoding to config I/O; breaks test isolation. Config loading belongs in `cmd/netsynth/main.go` (calls `config.Load`) or a dedicated `config` package.
- **Validate waveform strings after merge:** Validate before merging so the error is caught at startup (D-11), not silently ignored.
- **Deep-copy using `=` assignment on map values:** `synth.FreqConfig` contains a `[]HarmonicDef` slice; a simple struct copy shares the underlying array. Use an explicit copy of the slice if you mutate `Harmonics` during merge. (The merge code above reconstructs harmonics from the preset, so this is safe — but important to be aware of.)
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| TOML parsing | Custom parser | `BurntSushi/toml` v1.6.0 | TOML 1.1 compliance, error messages, datetime support, tested at scale |
| Unknown-key detection | Post-parse key comparison | `md.Undecoded()` from BurntSushi | Already built into the library; handles nested paths correctly |
| XDG config path | Manual `$HOME/.config` string concat | `os.UserConfigDir()` stdlib | Handles `$XDG_CONFIG_HOME` override correctly, platform-portable |
**Key insight:** The partial-override merge logic is the one piece that must be written from scratch — no library does "overlay a sparse map of optional overrides over a typed defaults map." But it is ~20 lines of straightforward Go.
## Runtime State Inventory
Step 2.5 SKIPPED — this is a new feature addition, not a rename/refactor/migration phase. No runtime state is being renamed or migrated.
## Common Pitfalls
### Pitfall 1: Undecoded() Does Not Catch Unknown Class Names
**What goes wrong:** Developer assumes `md.Undecoded()` will catch `[sounds.ICMP_TYPO]` as an unknown key and provide CFG-05 coverage for class-name typos.
**Why it happens:** `map[string]SoundOverride` decodes any string as a valid map key — the TOML parser has no way to know which class names are valid. `Undecoded()` only catches keys that don't match ANY field (struct field name, map key, or slice element). Since all map keys are valid, no class name is "undecodeable."
**How to avoid:** Separate the two concerns. Field-level unknown keys (e.g., `frequncy`) ARE caught by `Undecoded()`. Class-name typos are caught in the merge step by checking whether `classify.TrafficClass(className)` exists in `synth.ClassFreqConfigs`. The CONTEXT.md decision D-09 says unknown class names produce a warning (not error) to allow for Phase 7 user-defined classes — so this is by design.
**Warning signs:** Test for both: write a test with `[sounds.ICMP]` containing `frequncy = 440` (should error) AND a test with `[sounds.ICMP_TYPO]` containing `frequency = 440` (should warn, not error).
### Pitfall 2: Partial Override Accidentally Clears WaveformType
**What goes wrong:** User sets only `frequency = 300` for ICMP. After merge, ICMP's `WaveformType` is reset to `WaveformCustom` because the merge loop creates a new `FreqConfig{}` instead of starting from the default.
**Why it happens:** Copy-by-value from defaults is skipped, or merge starts from a zero-value struct.
**How to avoid:** Always start the merge from the DEFAULT `FreqConfig` for that class. The merge loop copies `defaults[class]` first, then overlays only non-nil pointer fields.
**Warning signs:** Test case: set only `frequency` for a class with `WaveformCustom` — verify waveform field is unchanged. Test case: set only `waveform` for a class — verify frequency is unchanged.
### Pitfall 3: Frequency Change Does Not Regenerate Harmonics for Preset Waveforms
**What goes wrong:** User sets `frequency = 300` for HTTPS (which has `WaveformCustom` by default, so this is fine). But if a user sets `frequency = 300` for a class that was previously configured with `WaveformSine` (via an earlier config entry), the harmonics may be stale from the old frequency.
**Why it happens:** `synth.WaveformPresetHarmonics` generates harmonics based on `baseHz`. If you update `BaseHz` without regenerating harmonics, the preset harmonics are anchored to the old frequency.
**How to avoid:** In the merge function: when updating `Frequency`, check if `WaveformType != WaveformCustom`. If true, regenerate `Harmonics` from the new frequency. The merge example above handles this correctly.
**Warning signs:** For the 14 built-in classes, all have `WaveformCustom` (hand-tuned harmonics), so this pitfall only bites if the user sets both `waveform` and `frequency` in two separate steps — or if a future phase pre-configures preset waveforms on built-ins.
### Pitfall 4: --config File-Not-Found vs Auto-Discovery Silence
**What goes wrong:** When `--config /path/to/missing.toml` is specified, the code returns the same "no config found, using defaults" behavior as auto-discovery silence.
**Why it happens:** `os.Stat` errors are treated uniformly regardless of how the path was obtained.
**How to avoid:** In the `Load` function, branch on whether `configPath` was explicitly provided: if it was, a `fs.ErrNotExist` is a user error (return error); if it came from auto-discovery, `fs.ErrNotExist` is normal (return `nil` error, use defaults).
**Warning signs:** CFG-03 acceptance criterion explicitly tests this: explicit path must error, absent auto-discovery must be silent.
### Pitfall 5: go.mod Tidy Drops TOML Dependency
**What goes wrong:** `go mod tidy` is run after adding BurntSushi/toml to go.mod but before any `.go` file in the module actually imports it. Tidy removes it.
**Why it happens:** `go mod tidy` removes unused dependencies.
**How to avoid:** Add the import in `config/config.go` before running `go mod tidy`.
## Code Examples
### Complete config.go Skeleton
```go
// Source: BurntSushi/toml docs + project pattern
// config/config.go
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"github.com/netsynth/netsynth/classify"
"github.com/netsynth/netsynth/synth"
)
type SoundOverride struct {
Frequency *float64 `toml:"frequency"`
Waveform *string `toml:"waveform"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
}
// Load is the single public entry point.
// configPath: value of --config flag; empty = auto-discover.
func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error) {
path, explicit, err := resolvePath(configPath)
if err != nil {
return nil, err
}
if path == "" {
// No config found during auto-discovery — use defaults silently (CFG-02)
return copyDefaults(), nil
}
raw, err := parseFile(path)
if err != nil {
if explicit && errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("config file not found: %s", path)
}
return nil, err
}
if err := validate(raw); err != nil {
return nil, err
}
return merge(copyDefaults(), raw.Sounds), nil
}
```
### Example TOML Config File
```toml
# netsynth.toml — override ICMP and SSH sounds
[sounds.ICMP]
frequency = 80.0
waveform = "square"
[sounds.SSH]
frequency = 400.0
# waveform not set — SSH keeps its default waveform
```
### Test Pattern (table-driven)
```go
// config/config_test.go
func TestLoadPartialOverride(t *testing.T) {
// Write a temp TOML file with only frequency for ICMP
tomlContent := `
[sounds.ICMP]
frequency = 100.0
`
f, _ := os.CreateTemp(t.TempDir(), "*.toml")
f.WriteString(tomlContent)
f.Close()
cfgs, err := Load(f.Name())
if err != nil {
t.Fatalf("Load: %v", err)
}
// ICMP frequency overridden
if cfgs[classify.ClassICMP].BaseHz != 100.0 {
t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz)
}
// ICMP waveform unchanged (WaveformCustom = 0)
if cfgs[classify.ClassICMP].WaveformType != synth.WaveformCustom {
t.Errorf("ICMP WaveformType: got %v, want WaveformCustom", cfgs[classify.ClassICMP].WaveformType)
}
// DNS frequency unchanged
if cfgs[classify.ClassDNS].BaseHz != synth.ClassFreqConfigs[classify.ClassDNS].BaseHz {
t.Errorf("DNS BaseHz unexpectedly changed")
}
}
func TestLoadUnknownKey(t *testing.T) {
tomlContent := `
[sounds.ICMP]
frequncy = 440
`
f, _ := os.CreateTemp(t.TempDir(), "*.toml")
f.WriteString(tomlContent)
f.Close()
_, err := Load(f.Name())
if err == nil {
t.Fatal("expected error for unknown key 'frequncy', got nil")
}
if !strings.Contains(err.Error(), "frequncy") {
t.Errorf("error should name the bad key, got: %v", err)
}
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `google/gopacket` | `gopacket/gopacket` v1.5.0 | 2022-2024 | N/A for this phase |
| BurntSushi/toml v0.x | v1.6.0 (TOML 1.1 enabled by default) | December 2025 | TOML 1.1 compliance; API unchanged, same `Decode`/`DecodeFile` functions |
| `go-audio/generator` | ARCHIVED (Feb 2026, read-only) | February 2026 | Do not use; project already avoids it |
**Current versions confirmed 2026-03-26:**
- `BurntSushi/toml` v1.6.0 (December 18, 2025) — TOML 1.1 default, stable API
- `pelletier/go-toml/v2` v2.3.0 (March 24, 2026) — alternative if structured error needed
## Open Questions
1. **Typo suggestion for unknown keys (D-07 nice-to-have)**
- What we know: BurntSushi returns `[]toml.Key` (structured), Levenshtein distance is ~15 lines of Go or `github.com/agnivade/levenshtein` (tiny, zero-dependency)
- What's unclear: Is the complexity worth it for 2 valid field names per class block (`frequency`, `waveform`)?
- Recommendation: Skip the external library. Implement inline: for each undecoded key, if it has edit distance ≤ 2 from any valid key name, append " (did you mean: X?)" to the error. The valid key set for field names is small and static: `["frequency", "waveform"]`. This is ~10 lines of Go.
2. **copyDefaults() — shallow vs deep copy of Harmonics slices**
- What we know: `synth.FreqConfig.Harmonics` is a `[]HarmonicDef`. Go's `map[K]V` assignment copies struct values (including slice headers) but the underlying array is shared.
- What's unclear: Does this matter if merge only replaces the whole slice (via `WaveformPresetHarmonics`) rather than appending to it?
- Recommendation: Since the merge code assigns a freshly-generated `[]HarmonicDef` from `WaveformPresetHarmonics` (never mutates the original), shallow copy is safe. No deep copy needed. Document this in a comment for future maintainers.
## Environment Availability
Step 2.6: This phase introduces one new external dependency:
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `github.com/BurntSushi/toml` | config.Load() TOML parsing | ✓ (fetched via go get) | v1.6.0 | pelletier/go-toml v2.3.0 |
| `os.UserConfigDir()` | Auto-discovery of `~/.config/netsynth/config.toml` | ✓ (Go stdlib) | Go 1.13+ | N/A — stdlib |
| Go 1.24.1 toolchain | Module minimum | ✓ | 1.24.1 | N/A |
| C compiler (CGo) | go-lame MP3 encoding (pre-existing) | Assumed ✓ (Phase 2+ already requires this) | — | N/A |
No missing dependencies with no fallback. BurntSushi/toml confirmed fetchable from pkg.go.dev.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go standard `testing` package |
| Config file | None — `go test ./...` |
| Quick run command | `go test ./config/...` |
| Full suite command | `go test ./...` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CFG-01 | TOML overrides applied to correct class | unit | `go test ./config/... -run TestLoadOverride` | Wave 0 |
| CFG-02 | No config file → silent, uses defaults | unit | `go test ./config/... -run TestLoadNoConfig` | Wave 0 |
| CFG-03 | `--config` explicit path → error if missing | unit | `go test ./config/... -run TestLoadExplicitMissing` | Wave 0 |
| CFG-04 | Partial override: unset fields unchanged | unit | `go test ./config/... -run TestLoadPartialOverride` | Wave 0 |
| CFG-05 | Unknown key → error naming the key | unit | `go test ./config/... -run TestLoadUnknownKey` | Wave 0 |
| CFG-03 | `--config` flag wired in Cobra | integration | `go test ./cmd/netsynth/... -run TestConfigFlag` | Wave 0 |
### Sampling Rate
- **Per task commit:** `go test ./config/... -count=1`
- **Per wave merge:** `go test ./... -count=1`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `config/config.go` — package does not exist yet; create in Wave 1
- [ ] `config/config_test.go` — covers CFG-01 through CFG-05
- [ ] `cmd/netsynth/main_test.go` — add `TestConfigFlag` covering CFG-03 CLI integration
*(Existing test infrastructure covers all other packages; only `config/` is new.)*
## Sources
### Primary (HIGH confidence)
- `pkg.go.dev/github.com/BurntSushi/toml` — v1.6.0 API: `DecodeFile`, `MetaData.Undecoded()`, `[]Key` type; verified 2026-03-26
- Go stdlib `os.UserConfigDir()` — returns `$XDG_CONFIG_HOME` or `$HOME/.config` on Linux; Go 1.13+ feature
- `pkg.go.dev/github.com/pelletier/go-toml/v2` — v2.3.0 `DisallowUnknownFields()` / `StrictMissingError` API; verified 2026-03-26
### Secondary (MEDIUM confidence)
- WebSearch: BurntSushi/toml Undecoded() approach verified against official GitHub source (`toml/decode.go`)
- WebSearch: pelletier/go-toml v2 DisallowUnknownFields verified against official docs
- WebSearch: `os.UserConfigDir` XDG compliance — confirmed returns `$XDG_CONFIG_HOME` or `$HOME/.config` on Linux per golang/go issue #29960
### Tertiary (LOW confidence)
- WebSearch: edit distance typo suggestion libraries (agnivade/levenshtein, go-edlib) — not deeply evaluated; recommendation is inline 10-line implementation to avoid dependency
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — versions confirmed via `go get` live fetch (v1.6.0 BurntSushi, v2.3.0 pelletier)
- Architecture: HIGH — patterns derived from library documentation + existing codebase patterns
- Pitfalls: HIGH — Undecoded() + map key limitation is a documented behavior; partial-override via pointer fields is an established Go idiom
- TOML typo suggestion: LOW — nice-to-have from D-07; no deep investigation needed given small valid-key set
**Research date:** 2026-03-26
**Valid until:** 2026-06-26 (BurntSushi/toml is stable; go-toml v2 moves faster but is not the chosen library)
@@ -0,0 +1,80 @@
---
phase: 06
slug: config-package-and-sound-overrides
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-26
---
# Phase 06 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | go test (stdlib) |
| **Config file** | none — tests use in-memory TOML strings |
| **Quick run command** | `go test ./config/... -count=1` |
| **Full suite command** | `go test ./... -count=1` |
| **Estimated runtime** | ~2 seconds |
---
## Sampling Rate
- **After every task commit:** Run `go test ./config/... -count=1`
- **After every plan wave:** Run `go test ./... -count=1`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 2 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 06-01-01 | 01 | 1 | CFG-01, CFG-04 | unit | `go test ./config/... -run TestParse` | ❌ W0 | ⬜ pending |
| 06-01-02 | 01 | 1 | CFG-02, CFG-03 | unit | `go test ./config/... -run TestDiscover` | ❌ W0 | ⬜ pending |
| 06-01-03 | 01 | 1 | CFG-05 | unit | `go test ./config/... -run TestUnknown` | ❌ W0 | ⬜ pending |
| 06-02-01 | 02 | 2 | CFG-01, CFG-04 | integration | `go test ./... -run TestRunSynthesis` | ❌ W0 | ⬜ pending |
| 06-02-02 | 02 | 2 | CFG-03 | integration | `go test ./cmd/... -run TestConfig` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `config/config_test.go` — stubs for parse, discover, validate, merge tests
- [ ] Existing `go test` infrastructure covers all phase requirements
*Existing test infrastructure (go test) covers all phase requirements. New test files created alongside implementation.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Auto-discover from `~/.config/netsynth/config.toml` | CFG-02 | Requires real home directory | Create config in `~/.config/netsynth/`, run netsynth, verify it loads |
| Ctrl+C after config load | CFG-01 | End-to-end with capture | Load config, start capture, Ctrl+C, verify MP3 uses overridden frequency |
*Most behaviors have automated verification via unit tests with in-memory TOML.*
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 2s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
@@ -0,0 +1,110 @@
---
phase: 06-config-package-and-sound-overrides
verified: 2026-03-26T20:15:00Z
status: passed
score: 10/10 must-haves verified
re_verification: false
---
# Phase 6: Config Package and Sound Overrides Verification Report
**Phase Goal:** Users can create a TOML config file to override frequency and waveform per traffic class, with auto-discovery, partial override semantics, and clear validation errors
**Verified:** 2026-03-26T20:15:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|----|-------------------------------------------------------------------------------------|------------|--------------------------------------------------------------------------------------------|
| 1 | Load with explicit path to valid TOML returns merged config map with overrides applied | ✓ VERIFIED | TestLoadPartialOverrideFrequency, TestLoadBothOverrides — PASS |
| 2 | Load with no config file found returns default ClassFreqConfigs unchanged | ✓ VERIFIED | TestLoadNoConfig (t.Chdir to empty tmpdir) — PASS |
| 3 | Load with unknown TOML key returns error naming the bad key | ✓ VERIFIED | TestLoadUnknownKey ("frequncy") — PASS; error contains the typo'd key name |
| 4 | Load with partial override (only frequency set) leaves waveform unchanged | ✓ VERIFIED | TestLoadPartialOverrideFrequency — WaveformType remains WaveformCustom — PASS |
| 5 | Load with partial override (only waveform set) leaves frequency unchanged | ✓ VERIFIED | TestLoadPartialOverrideWaveform — BaseHz remains 65.0 — PASS |
| 6 | Load with unknown class name logs warning and does not error | ✓ VERIFIED | TestLoadUnknownClass (BOGUS class) — err == nil, 14 entries, warning to stderr — PASS |
| 7 | User passes --config /path/to/file.toml and tool uses that file for sound overrides | ✓ VERIFIED | config.Load(configPath) called in run() at line 87; flows to RunSynthesis and NewBank |
| 8 | User passes --config /nonexistent.toml and tool exits with clear error before capture | ✓ VERIFIED | Tested live: `go run ./cmd/netsynth --config /nonexistent/file.toml` exits 1 with "config file not found: /nonexistent/file.toml" |
| 9 | User runs without --config and auto-discovery kicks in (or defaults used silently) | ✓ VERIFIED | discoverPath() checks ./netsynth.toml then XDG dir; silent default on no-find |
| 10 | RunSynthesis uses the merged config map instead of hardcoded ClassFreqConfigs | ✓ VERIFIED | encode/mp3.go line 58: `synth.NewBank(1.0, freqCfgs)` — no reference to ClassFreqConfigs |
**Score:** 10/10 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|-------------------------|---------------------------------------------------|------------|------------------------------------------------------------------------|
| `config/config.go` | Load function, parse, validate, merge, discover | ✓ VERIFIED | 184 lines; exports Load, SoundOverride, rawConfig; all functions present |
| `config/config_test.go` | Table-driven tests for CFG-01 through CFG-05 | ✓ VERIFIED | 181 lines; 9 test functions (TestLoad*); all 9 pass |
| `cmd/netsynth/main.go` | --config flag, config.Load call, freqCfgs to RunSynthesis | ✓ VERIFIED | configPath var, flag registration, config.Load at line 87, two RunSynthesis call sites updated |
| `encode/mp3.go` | RunSynthesis with freqCfgs parameter | ✓ VERIFIED | Signature: `func RunSynthesis(..., freqCfgs map[classify.TrafficClass]synth.FreqConfig) error` |
| `encode/mp3_test.go` | Updated tests for new RunSynthesis signature | ✓ VERIFIED | Three call sites pass `synth.ClassFreqConfigs` as third arg |
### Key Link Verification
| From | To | Via | Status | Details |
|---------------------------|---------------------------|--------------------------------------------------|------------|-----------------------------------------------------------|
| `config/config.go` | `synth/config.go` | synth.FreqConfig, ClassFreqConfigs, WaveformPresetHarmonics | ✓ WIRED | grep confirmed all three at lines 46, 147-148, 172, 178 |
| `config/config.go` | `classify/types.go` | classify.TrafficClass (AllClasses implied) | ✓ WIRED | Line 161: `classify.TrafficClass(className)` confirmed |
| `config/config.go` | `github.com/BurntSushi/toml` | toml.DecodeFile, md.Undecoded() | ✓ WIRED | Lines 104 and 115 confirmed; dependency in go.mod |
| `cmd/netsynth/main.go` | `config/config.go` | config.Load(configPath) | ✓ WIRED | Line 87: `freqCfgs, err := config.Load(configPath)` |
| `cmd/netsynth/main.go` | `encode/mp3.go` | encode.RunSynthesis(snapshots, outputPath, freqCfgs) | ✓ WIRED | Lines 157 and 225 — both runLiveMode and runPcapMode |
| `encode/mp3.go` | `synth/bank.go` | synth.NewBank(1.0, freqCfgs) using passed-in config | ✓ WIRED | Line 58: `synth.NewBank(1.0, freqCfgs)` — no hardcoding |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|-----------------------|---------------|-------------------------------|--------------------|-------------|
| `config/config.go` | result map | synth.ClassFreqConfigs + TOML overrides | Yes — copies from ClassFreqConfigs (14 entries), overlays TOML | ✓ FLOWING |
| `encode/mp3.go` | freqCfgs | Injected from config.Load | Yes — passed in from caller, not hardcoded | ✓ FLOWING |
| `cmd/netsynth/main.go`| freqCfgs | config.Load(configPath) return | Yes — real config.Load result, error-guarded | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------|---------|
| `--config` flag appears in CLI help | `go run ./cmd/netsynth --help` | `--config string Path to TOML config file (default: auto-discover)` | ✓ PASS |
| Explicit --config missing file errors before capture | `go run ./cmd/netsynth --config /nonexistent/file.toml --read /dev/null` | exit 1, "config file not found: /nonexistent/file.toml" | ✓ PASS |
| All 9 config package tests pass | `go test ./config/... -count=1 -v` | All 9 TestLoad* PASS | ✓ PASS |
| Full test suite green | `go test ./... -count=1` | 7 packages all ok | ✓ PASS |
| Binary builds and vets clean | `go build ./... && go vet ./...` | BUILD OK, VET OK | ✓ PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|------------------------------------------------------------------------|-------------|------------------------------------------------------------------|
| CFG-01 | 06-01 | User can create a TOML config file that overrides default sound mappings | ✓ SATISFIED | config.Load + merge; TestLoadPartialOverrideFrequency PASS |
| CFG-02 | 06-01 | Tool auto-discovers config from ./netsynth.toml or ~/.config/netsynth/config.toml (silent if absent) | ✓ SATISFIED | discoverPath(); TestLoadNoConfig PASS (t.Chdir to empty dir) |
| CFG-03 | 06-02 | User can specify explicit config path via --config flag (error if missing) | ✓ SATISFIED | --config flag registered; config.Load returns "not found" error |
| CFG-04 | 06-01 | User can override individual values without replicating entire default config | ✓ SATISFIED | Pointer fields (*float64, *string); TestLoadPartialOverrideWaveform PASS |
| CFG-05 | 06-01 | Unknown keys in config file produce a clear error with the typo'd key name | ✓ SATISFIED | md.Undecoded() + keyPath extraction; TestLoadUnknownKey PASS |
All 5 requirement IDs from both PLAN frontmatter entries (CFG-01, CFG-02, CFG-04, CFG-05 from 06-01; CFG-03 from 06-02) are satisfied with evidence.
**Orphaned requirements check:** REQUIREMENTS.md traceability table maps CFG-01 through CFG-05 to Phase 6. All 5 are claimed and verified. No orphans.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|--------------------|------|-------------------|----------|---------|
| `go.mod` | 14 | BurntSushi/toml marked `// indirect` despite being a direct import in config/config.go | Info | None functional — `go mod tidy` corrects it; does not affect build or tests |
No placeholders, stub functions, hardcoded empty returns, or TODO markers found in any phase 6 modified files.
### Human Verification Required
No items require human verification. All functional behaviors were confirmed programmatically:
- Config loading, merging, and validation verified via unit tests.
- CLI flag confirmed in help output.
- Error-before-capture behavior confirmed via live CLI invocation.
### Gaps Summary
No gaps. All 10 observable truths verified, all 5 artifacts substantive and wired, all 6 key links confirmed, all 5 requirements satisfied. The single info-level finding (BurntSushi/toml marked indirect in go.mod) is a trivial go module hygiene item — `go mod tidy` resolves it and it has no impact on correctness or functionality.
---
_Verified: 2026-03-26T20:15:00Z_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,302 @@
---
phase: 07-custom-rules-and-print-config
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- config/config.go
- config/config_test.go
autonomous: true
requirements:
- RULE-01
- RULE-02
- RULE-03
must_haves:
truths:
- "TOML [[rules]] blocks parse into classify.Rule slices"
- "Missing protocol or class in a rule produces a clear error at startup"
- "User rules are returned separately from FreqCfgs for caller to prepend"
- "New class names without explicit sound config get auto-assigned frequencies in 1200-2400 Hz range"
- "Built-in class names in user rules do not get overwritten by auto-freq"
artifacts:
- path: "config/config.go"
provides: "RawRule, LoadResult, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries"
contains: "type LoadResult struct"
- path: "config/config_test.go"
provides: "Tests for rule parsing, validation, auto-freq, LoadResult"
contains: "TestLoadCustomRules"
key_links:
- from: "config/config.go"
to: "classify/rules.go"
via: "convertRules produces []classify.Rule"
pattern: "classify\\.Rule"
- from: "config/config.go"
to: "synth/config.go"
via: "autoAssignFreq creates FreqConfig entries with WaveformPresetHarmonics"
pattern: "synth\\.WaveformPresetHarmonics"
---
<objective>
Extend the config package to parse `[[rules]]` TOML blocks into classification rules, validate them, auto-assign frequencies for new class names, and return a `LoadResult` struct from `Load()`.
Purpose: This is the data layer for user-defined classification rules (RULE-01, RULE-02, RULE-03). The LoadResult struct becomes the contract consumed by Plan 02 for CLI wiring and print-config.
Output: Updated `config/config.go` with RawRule, LoadResult, validation, auto-freq; comprehensive tests in `config/config_test.go`.
</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/07-custom-rules-and-print-config/07-CONTEXT.md
@.planning/phases/07-custom-rules-and-print-config/07-RESEARCH.md
@config/config.go
@config/config_test.go
@classify/rules.go
@classify/types.go
@synth/config.go
<interfaces>
<!-- Key types and contracts the executor needs. -->
From classify/rules.go:
```go
type Rule struct {
Protocol string
DstPort uint16
Class TrafficClass
}
var DefaultRules = []Rule{ ... } // 12 rules, first-match-wins
```
From classify/types.go:
```go
type TrafficClass string
func AllClasses() []TrafficClass // returns 14 built-in classes
```
From synth/config.go:
```go
type WaveformType int
const WaveformSine WaveformType = 1
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries, max 1047 Hz
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef
const SampleRate = 44100
```
From config/config.go (current):
```go
type SoundOverride struct {
Frequency *float64 `toml:"frequency"`
Waveform *string `toml:"waveform"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
}
func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)
```
From config/config_test.go (patterns):
```go
func writeTOML(t *testing.T, content string) string // creates temp TOML file
// Tests call config.Load(path) and check returned map
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: RawRule, LoadResult, validation, conversion, and auto-freq with TDD</name>
<files>config/config.go, config/config_test.go</files>
<read_first>config/config.go, config/config_test.go, classify/rules.go, classify/types.go, synth/config.go</read_first>
<behavior>
- TestLoadCustomRules: TOML with `[[rules]]` block (port=8080, protocol="tcp", class="MyApp") + `[sounds.MyApp]` (frequency=300.0) parses successfully; LoadResult.UserRules has len 1 with Protocol="tcp", DstPort=8080, Class="MyApp"; LoadResult.FreqCfgs["MyApp"].BaseHz == 300.0
- TestLoadCustomRuleNoPort: TOML with `[[rules]]` (protocol="udp", class="AllUDP", no port field) parses; UserRules[0].DstPort == 0
- TestLoadCustomRuleMissingProtocol: TOML `[[rules]]` with class="X" but no protocol field -> error containing "protocol is required"
- TestLoadCustomRuleMissingClass: TOML `[[rules]]` with protocol="tcp" but no class field -> error containing "class is required"
- TestLoadCustomRuleInvalidProtocol: TOML `[[rules]]` with protocol="ftp" -> error containing "invalid protocol"
- TestLoadCustomRuleUnknownField: TOML `[[rules]]` with typo_field="bad" -> error containing "typo_field" (from Undecoded())
- TestUserRulesPrepend: Load returns UserRules separately from FreqCfgs; caller can do `append(result.UserRules, classify.DefaultRules...)` to get user rules first
- TestAutoFreqAssignment: TOML with `[[rules]]` (class="GameServer", protocol="tcp") and NO `[sounds.GameServer]` -> FreqCfgs contains "GameServer" entry with BaseHz in range [1200, 2350] and WaveformType == WaveformSine
- TestAutoFreqDeterministic: Two Load() calls with same class name produce same BaseHz
- TestAutoFreqSkipsBuiltins: TOML with `[[rules]]` (class="HTTPS", protocol="tcp", port=443) -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value
- TestLoadResultConfigPath: Load(explicit_path) -> LoadResult.ConfigPath == explicit_path; Load("") with no file -> LoadResult.ConfigPath == ""
- TestLoadNoConfigReturnsLoadResult: Load("") in empty dir returns LoadResult with len(FreqCfgs)==14, len(UserRules)==0, ConfigPath==""
- TestExistingTestsStillPass: All 8 existing tests in config_test.go continue to pass after Load() signature change (they need updating to use LoadResult)
</behavior>
<action>
RED phase -- Write all test functions listed in behavior above in config/config_test.go. Tests call config.Load() and assert on LoadResult fields. Update the 8 existing tests to use the new LoadResult return type (e.g., `result, err := config.Load(path); cfgs := result.FreqCfgs`). Run tests -- they must all fail (Load still returns bare map).
GREEN phase -- Modify config/config.go:
1. Add RawRule struct (per D-01):
```go
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
```
2. Add Rules field to rawConfig:
```go
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
```
3. Add LoadResult struct (per D-13, Claude's Discretion: struct over tuple):
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
}
```
4. Add validateRules function called from validate():
```go
func validateRules(rules []RawRule) error {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
for i, r := range rules {
if r.Protocol == "" {
return fmt.Errorf("config: rules[%d]: protocol is required", i)
}
if !validProtocols[r.Protocol] {
return fmt.Errorf("config: rules[%d]: invalid protocol %q -- valid: tcp, udp, icmp", i, r.Protocol)
}
if r.Class == "" {
return fmt.Errorf("config: rules[%d]: class is required", i)
}
}
return nil
}
```
5. Add convertRules function:
```go
func convertRules(raw []RawRule) []classify.Rule {
result := make([]classify.Rule, len(raw))
for i, r := range raw {
var port uint16
if r.Port != nil {
port = *r.Port
}
result[i] = classify.Rule{
Protocol: r.Protocol,
DstPort: port,
Class: classify.TrafficClass(r.Class),
}
}
return result
}
```
6. Add autoAssignFreq function (per D-07/D-08, using FNV-32a):
```go
import "hash/fnv"
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = uint32(24)
)
return baseHz + float64(h.Sum32()%numSteps)*stepHz
}
```
7. Add addAutoFreqEntries function (called AFTER merge, per Pitfall 4):
```go
func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule) {
for _, rule := range userRules {
if _, exists := cfgs[rule.Class]; !exists {
baseHz := autoAssignFreq(string(rule.Class))
cfgs[rule.Class] = synth.FreqConfig{
BaseHz: baseHz,
WaveformType: synth.WaveformSine,
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0,
}
}
}
}
```
8. Change Load() signature to return LoadResult:
```go
func Load(configPath string) (LoadResult, error) {
```
- When no config found: return `LoadResult{FreqCfgs: copyDefaults(), ConfigPath: ""}`, nil
- After parseFile + validate + merge: call convertRules, call addAutoFreqEntries, return LoadResult with FreqCfgs, UserRules, and the resolved config path
- Update validate() to also call validateRules(raw.Rules)
- The `merge()` function for unknown class names should NO LONGER print a warning for classes that appear in raw.Rules -- those are legitimate custom classes. Keep the warning only for [sounds.X] where X is neither a built-in class nor a class defined in [[rules]].
9. Update the merge() function: Change the unknown-class warning logic. Instead of always warning on unknown class names in sounds, accept the `userRules []RawRule` as a parameter (or check after conversion). Simplest: after converting rules, pass the set of user-defined class names to merge so it can skip the warning for those. Alternatively, run merge first (with warnings), then let addAutoFreqEntries handle user-defined classes. The warning is acceptable for now -- it only fires for [sounds.X] where X has no matching [[rules]] entry AND is not a built-in class. Keep existing warning behavior, it is harmless.
REFACTOR phase -- Clean up if needed. Ensure all tests pass.
Run `go test ./config/...` -- all tests must pass.
Run `go test ./...` -- full suite must pass (the Load() call site in main.go will break; that is expected and fixed in Plan 02).
</action>
<verify>
<automated>go test ./config/... -v -count=1</automated>
</verify>
<acceptance_criteria>
- config/config.go contains `type RawRule struct` with `Port *uint16`, `Protocol string`, `Class string` fields
- config/config.go contains `type LoadResult struct` with `FreqCfgs`, `UserRules`, `ConfigPath` fields
- config/config.go contains `func Load(configPath string) (LoadResult, error)`
- config/config.go contains `func validateRules(rules []RawRule) error`
- config/config.go contains `func convertRules(raw []RawRule) []classify.Rule`
- config/config.go contains `func autoAssignFreq(className string) float64` with `fnv.New32a()`
- config/config.go contains `func addAutoFreqEntries(`
- config/config.go imports `"hash/fnv"`
- config/config_test.go contains `TestLoadCustomRules`
- config/config_test.go contains `TestAutoFreqAssignment`
- config/config_test.go contains `TestAutoFreqSkipsBuiltins`
- config/config_test.go contains `TestLoadCustomRuleMissingProtocol`
- `go test ./config/... -count=1` exits 0
</acceptance_criteria>
<done>
Load() returns LoadResult with FreqCfgs + UserRules + ConfigPath. TOML [[rules]] blocks parse, validate (protocol required, class required, valid protocols only), and convert to classify.Rule slices. Auto-frequency assignment creates FreqConfig entries for new class names in 1200-2350 Hz range using FNV-32a. Built-in class names from user rules are NOT overwritten. All existing config tests updated and passing. Full config test suite green.
</done>
</task>
</tasks>
<verification>
- `go test ./config/... -v -count=1` passes all tests including new rule-related tests
- `go vet ./config/...` reports no issues
- LoadResult struct is exported and usable from cmd/netsynth package
</verification>
<success_criteria>
- config.Load() returns LoadResult struct (not bare map)
- [[rules]] TOML blocks parse into UserRules field
- Validation catches missing protocol, missing class, invalid protocol
- Auto-freq assigns 1200-2350 Hz for new class names, skips built-ins
- All 8 existing config tests updated and passing
- All new tests passing
</success_criteria>
<output>
After completion, create `.planning/phases/07-custom-rules-and-print-config/07-01-SUMMARY.md`
</output>
@@ -0,0 +1,94 @@
---
phase: 07-custom-rules-and-print-config
plan: "01"
subsystem: config
tags: [config, rules, tdd, classification, auto-freq]
dependency_graph:
requires: []
provides: [LoadResult, RawRule, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries]
affects: [cmd/netsynth/main.go]
tech_stack:
added: ["hash/fnv"]
patterns: [LoadResult-struct, FNV-32a-deterministic-hash, TDD-red-green]
key_files:
created: []
modified:
- config/config.go
- config/config_test.go
- cmd/netsynth/main.go
decisions:
- "addAutoFreqEntries runs before merge so [sounds.X] overrides apply to user-defined classes"
- "merge() warning for unknown class names still fires for [sounds.X] where X is neither built-in nor in [[rules]] -- acceptable harmless warning"
- "main.go call site updated to use LoadResult.FreqCfgs -- minimal fix to keep compile; full wiring deferred to Plan 02"
metrics:
duration: 3min
completed: "2026-03-26T20:45:08Z"
tasks_completed: 1
files_modified: 3
---
# Phase 7 Plan 01: Config Rule Parsing and LoadResult Summary
Extend config package to parse `[[rules]]` TOML blocks, validate them, auto-assign frequencies for new class names using FNV-32a, and return a `LoadResult` struct from `Load()`.
## What Was Built
`config.Load()` now returns `LoadResult{FreqCfgs, UserRules, ConfigPath}` instead of a bare map. The new struct is the data contract for Plan 02's CLI wiring and `--print-config` output.
**New types and functions in config/config.go:**
- `RawRule` struct: `Port *uint16`, `Protocol string`, `Class string` — pointer Port to distinguish missing vs zero
- `LoadResult` struct: `FreqCfgs`, `UserRules []classify.Rule`, `ConfigPath string`
- `validateRules()`: checks protocol required, class required, valid protocols (tcp/udp/icmp)
- `convertRules()`: converts `[]RawRule` to `[]classify.Rule`
- `autoAssignFreq()`: FNV-32a hash → deterministic Hz in [1200, 2350] range (24 steps of 50Hz)
- `addAutoFreqEntries()`: creates `FreqConfig` entries for new class names, skips built-ins
- Import: `hash/fnv`
**Key operation order:** `addAutoFreqEntries` runs before `merge` so that `[sounds.MyApp]` sound overrides apply to user-defined classes that were added by auto-freq.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | RawRule, LoadResult, validation, conversion, auto-freq with TDD | 4b365cd | config/config.go, config/config_test.go, cmd/netsynth/main.go |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Operation order: addAutoFreqEntries must run before merge**
- **Found during:** Task 1 GREEN phase
- **Issue:** Plan's action section said "merge first, then addAutoFreqEntries" but this caused [sounds.MyApp] overrides to be ignored for user-defined classes (merge only applies to classes already in the map)
- **Fix:** Reversed the order — addAutoFreqEntries first (creates the entry), then merge (applies sound overrides)
- **Files modified:** config/config.go
- **Commit:** 4b365cd
**2. [Rule 3 - Blocking] main.go call site updated to use LoadResult**
- **Found during:** Task 1 GREEN phase
- **Issue:** Plan notes this break is expected but tests wouldn't compile without it
- **Fix:** Minimal one-line update: `loadResult, err := config.Load(...)` + `freqCfgs := loadResult.FreqCfgs`
- **Files modified:** cmd/netsynth/main.go
- **Commit:** 4b365cd
## Test Coverage
21 tests total (8 existing + 13 new):
- `TestLoadCustomRules` - TOML [[rules]] block with port/protocol/class
- `TestLoadCustomRuleNoPort` - optional port field, DstPort=0 when absent
- `TestLoadCustomRuleMissingProtocol` - validation error "protocol is required"
- `TestLoadCustomRuleMissingClass` - validation error "class is required"
- `TestLoadCustomRuleInvalidProtocol` - validation error "invalid protocol"
- `TestLoadCustomRuleUnknownField` - undecoded TOML field error
- `TestUserRulesPrepend` - UserRules field usable for prepend pattern
- `TestAutoFreqAssignment` - BaseHz in [1200, 2350], WaveformSine
- `TestAutoFreqDeterministic` - same class name produces same Hz
- `TestAutoFreqSkipsBuiltins` - HTTPS stays at 175.0 default
- `TestLoadResultConfigPath` - ConfigPath populated correctly
- `TestLoadNoConfigReturnsLoadResult` - returns LoadResult with empty UserRules
- All 8 existing tests updated to use `result.FreqCfgs`
## Known Stubs
None. All new functions are fully implemented and tested.
## Self-Check: PASSED
@@ -0,0 +1,444 @@
---
phase: 07-custom-rules-and-print-config
plan: 02
type: execute
wave: 2
depends_on:
- "07-01"
files_modified:
- cmd/netsynth/main.go
- cmd/netsynth/main_test.go
- config/config.go
- config/config_test.go
autonomous: true
requirements:
- RULE-02
- CFG-06
must_haves:
truths:
- "User runs netsynth --print-config and sees full effective config as commented TOML on stdout without capture starting"
- "User rules prepend before built-in rules so first-match-wins gives user priority"
- "Print-config output shows source path when config file loaded"
- "Print-config output annotates defaults vs overrides vs auto-assigned"
- "Print-config works without -i flag"
- "Print-config includes [[rules]] section when user rules are present"
artifacts:
- path: "cmd/netsynth/main.go"
provides: "--print-config flag, runPrintConfig(), user rule prepend"
contains: "print-config"
- path: "config/config.go"
provides: "PrintConfig function"
contains: "func PrintConfig("
- path: "cmd/netsynth/main_test.go"
provides: "Tests for --print-config flag"
contains: "TestPrintConfigFlagRegistered"
- path: "config/config_test.go"
provides: "Tests for PrintConfig output"
contains: "TestPrintConfigContainsAllClasses"
key_links:
- from: "cmd/netsynth/main.go"
to: "config/config.go"
via: "runPrintConfig calls config.Load then config.PrintConfig"
pattern: "config\\.PrintConfig"
- from: "cmd/netsynth/main.go"
to: "classify/rules.go"
via: "append(result.UserRules, classify.DefaultRules...)"
pattern: "append.*UserRules.*DefaultRules"
---
<objective>
Wire the LoadResult into main.go (user rules prepend, --print-config flag), implement the PrintConfig output function, and add comprehensive tests for both.
Purpose: Completes RULE-02 (user rules fire before built-ins at the CLI level) and CFG-06 (--print-config UX). This is the final plan for Phase 7 and the v1.1 milestone.
Output: Updated main.go with --print-config and user rule prepending; PrintConfig function in config package; tests in both test files.
</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/07-custom-rules-and-print-config/07-CONTEXT.md
@.planning/phases/07-custom-rules-and-print-config/07-RESEARCH.md
@.planning/phases/07-custom-rules-and-print-config/07-01-SUMMARY.md
@cmd/netsynth/main.go
@cmd/netsynth/main_test.go
@config/config.go
@config/config_test.go
@classify/rules.go
@classify/types.go
@synth/config.go
<interfaces>
<!-- Post-Plan-01 interfaces the executor needs -->
From config/config.go (after Plan 01):
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
}
func Load(configPath string) (LoadResult, error)
```
From classify/rules.go:
```go
var DefaultRules = []Rule{ ... } // 12 rules
func NewClassifier(rules []Rule) *Classifier
```
From synth/config.go:
```go
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
```
From classify/types.go:
```go
func AllClasses() []TrafficClass // 14 built-in classes in display order
```
From cmd/netsynth/main.go (current run() flow):
```go
// listIfaces check is first early-exit
// then mutual exclusion check for --read and -i
// then interface-required check
// then BPF filter validation
// then config.Load(configPath)
// then output path resolution
// then runLiveMode or runPcapMode
```
From cmd/netsynth/main_test.go (patterns):
```go
func newTestCmd() *cobra.Command // creates fresh command with all flags
// Tests use rootCmd.SetArgs, rootCmd.Execute(), check err
// PersistentPreRunE wires test vars to package-level vars
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire LoadResult into main.go and add --print-config flag</name>
<files>cmd/netsynth/main.go, cmd/netsynth/main_test.go</files>
<read_first>cmd/netsynth/main.go, cmd/netsynth/main_test.go, config/config.go, classify/rules.go</read_first>
<action>
**main.go changes:**
1. Add package-level var for print-config flag:
```go
var printConfig bool // add alongside existing configPath var
```
2. Register --print-config flag in main() alongside existing flags:
```go
rootCmd.Flags().BoolVar(&printConfig, "print-config", false, "Print effective config as commented TOML and exit")
```
3. In run(), add --print-config check as the SECOND early-exit (after listIfaces, BEFORE the mutual exclusion check). Per Pitfall 2 from research, this must come before the interface-required validation so `netsynth --print-config` works without `-i`:
```go
// --print-config mode (CFG-06, D-09/D-10)
if printConfig {
return runPrintConfig()
}
```
4. Add runPrintConfig function:
```go
func runPrintConfig() error {
result, err := config.Load(configPath)
if err != nil {
return err
}
output := config.PrintConfig(result)
fmt.Print(output)
return nil
}
```
5. Update ALL Load() call sites to use LoadResult (there is one in run()):
```go
// Load config (CFG-01 through CFG-05, D-11: fail fast before capture)
result, err := config.Load(configPath)
if err != nil {
return err
}
```
6. After config load, prepend user rules before creating classifier (per D-04, RULE-02). Update BOTH runLiveMode and runPcapMode. Change their signatures to accept LoadResult instead of bare map:
```go
func runLiveMode(cmd *cobra.Command, result config.LoadResult) error {
// ...
// D-04: user rules prepend before built-ins; first-match-wins
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
// ...use result.FreqCfgs where freqCfgs was used before...
}
```
Do the same for runPcapMode. Update the call sites in run():
```go
if readPath != "" {
return runPcapMode(cmd, result)
}
return runLiveMode(cmd, result)
```
7. In both runLiveMode and runPcapMode, replace `freqCfgs` parameter usage with `result.FreqCfgs` in the encode.RunSynthesis call:
```go
if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil {
```
**main_test.go changes:**
8. Update newTestCmd() to include --print-config flag and --config flag:
```go
var testPrintConfig bool
var testConfigPath string
// ...in flag registration:
rootCmd.Flags().BoolVar(&testPrintConfig, "print-config", false, "Print effective config")
rootCmd.Flags().StringVar(&testConfigPath, "config", "", "Path to TOML config file")
// ...in PersistentPreRunE:
printConfig = testPrintConfig
configPath = testConfigPath
```
9. Add TestPrintConfigFlagRegistered:
```go
func TestPrintConfigFlagRegistered(t *testing.T) {
rootCmd := newTestCmd()
f := rootCmd.Flags().Lookup("print-config")
if f == nil {
t.Fatal("expected --print-config flag to be registered")
}
}
```
10. Add TestPrintConfigNoInterface -- verifies --print-config works without -i:
```go
func TestPrintConfigNoInterface(t *testing.T) {
// Reset globals
ifaceName = ""
listIfaces = false
printConfig = false
configPath = ""
// ... reset all globals
t.Chdir(t.TempDir()) // no netsynth.toml in temp dir
rootCmd := newTestCmd()
rootCmd.SetArgs([]string{"--print-config"})
var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("--print-config should not require -i, got error: %v", err)
}
}
```
11. Add TestPrintConfigWithConfigFile -- verifies print-config loads and displays a user config:
```go
func TestPrintConfigWithConfigFile(t *testing.T) {
// Create temp TOML with an override
dir := t.TempDir()
tomlPath := filepath.Join(dir, "test.toml")
os.WriteFile(tomlPath, []byte("[sounds.ICMP]\nfrequency = 100.0\n"), 0644)
// Reset globals
// ...
rootCmd := newTestCmd()
rootCmd.SetArgs([]string{"--print-config", "--config", tomlPath})
var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
// Capture stdout by redirecting os.Stdout temporarily, OR
// check that no error occurred (PrintConfig writes to os.Stdout via fmt.Print)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("--print-config with --config should succeed, got: %v", err)
}
}
```
</action>
<verify>
<automated>go test ./cmd/netsynth/... -v -count=1 && go test ./config/... -count=1</automated>
</verify>
<acceptance_criteria>
- cmd/netsynth/main.go contains `var printConfig bool`
- cmd/netsynth/main.go contains `"print-config"` flag registration
- cmd/netsynth/main.go contains `if printConfig {` BEFORE the interface-required check
- cmd/netsynth/main.go contains `func runPrintConfig() error`
- cmd/netsynth/main.go contains `append(result.UserRules, classify.DefaultRules...)`
- cmd/netsynth/main.go contains `func runLiveMode(cmd *cobra.Command, result config.LoadResult)`
- cmd/netsynth/main.go contains `func runPcapMode(cmd *cobra.Command, result config.LoadResult)`
- cmd/netsynth/main_test.go contains `TestPrintConfigFlagRegistered`
- cmd/netsynth/main_test.go contains `TestPrintConfigNoInterface`
- cmd/netsynth/main_test.go contains `"print-config"` in newTestCmd()
- cmd/netsynth/main_test.go contains `"config"` flag in newTestCmd()
- `go test ./cmd/netsynth/... -count=1` exits 0
- `go test ./config/... -count=1` exits 0
</acceptance_criteria>
<done>
--print-config flag registered, checked before interface validation (no -i required). runPrintConfig calls config.Load then config.PrintConfig, prints to stdout, exits. User rules prepended in both runLiveMode and runPcapMode via append(result.UserRules, classify.DefaultRules...). All existing and new tests pass.
</done>
</task>
<task type="auto">
<name>Task 2: Implement PrintConfig output function with comment annotations</name>
<files>config/config.go, config/config_test.go</files>
<read_first>config/config.go, config/config_test.go, synth/config.go, classify/types.go, classify/rules.go</read_first>
<action>
Add the PrintConfig function to config/config.go and tests to config/config_test.go.
**config/config.go additions:**
1. Add an `AutoClasses` field to LoadResult to track which classes were auto-assigned (per Open Question 1 from research):
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies
}
```
Update addAutoFreqEntries to populate this map. Also update Load() to initialize the map.
2. Add the PrintConfig function. Use manual string building with fmt.Fprintf to a strings.Builder (per D-09, D-10, D-11). The function signature:
```go
func PrintConfig(result LoadResult) string
```
3. Output format (per Pattern 5 from research):
```
# NetSynth effective configuration
# Config source: <path or "none (using defaults)">
# Generated: <date>
```
Then, if result.UserRules is non-empty, emit the [[rules]] section:
```
# Classification rules (user-defined, prepended before built-in rules)
[[rules]]
port = 8080
protocol = "tcp"
class = "MyApp"
```
For each user rule, emit a `[[rules]]` block. If DstPort == 0, omit the `port` line (per D-02 semantics).
Then emit the [sounds] section. Iterate in a deterministic order: first AllClasses() (14 built-in classes in display order), then any user-defined classes sorted alphabetically. For each class:
```
# <ClassName> -- <BaseHz> Hz (<annotation>)
[sounds.<ClassName>]
frequency = <BaseHz>
waveform = "<waveform_string>"
```
Where annotation is:
- `default` -- class is in synth.ClassFreqConfigs AND FreqCfgs entry matches the default BaseHz and WaveformType
- `override` -- class is in synth.ClassFreqConfigs BUT FreqCfgs entry differs from default (user changed it)
- `auto-assigned` -- class is in result.AutoClasses
4. Add waveformString helper to convert WaveformType back to string:
```go
func waveformString(wt synth.WaveformType) string {
switch wt {
case synth.WaveformSine:
return "sine"
case synth.WaveformSquare:
return "square"
case synth.WaveformSawtooth:
return "sawtooth"
case synth.WaveformTriangle:
return "triangle"
default:
return "custom"
}
}
```
5. For deterministic ordering of user-defined classes (not in AllClasses()), collect them, sort by string value, and append after built-in classes.
**config/config_test.go additions:**
6. TestPrintConfigContainsAllClasses: Create a LoadResult with defaults (no overrides, no user rules). Call PrintConfig. Assert output contains all 14 class names from classify.AllClasses(): "ICMP", "DNS", "HTTPS", "HTTP", "SSH", "SMTP", "NTP", "DHCP", "other-TCP", "other-UDP", "unknown-1", "unknown-2", "unknown-3", "unknown-4".
7. TestPrintConfigSourcePath: Create LoadResult with ConfigPath="/home/user/netsynth.toml". Assert output contains `# Config source: /home/user/netsynth.toml`.
8. TestPrintConfigNoSourcePath: Create LoadResult with ConfigPath="". Assert output contains `# Config source: none`.
9. TestPrintConfigContainsRules: Create LoadResult with UserRules containing one rule (Protocol="tcp", DstPort=8080, Class="MyApp"). Assert output contains `[[rules]]`, `port = 8080`, `protocol = "tcp"`, `class = "MyApp"`.
10. TestPrintConfigRuleNoPort: Create LoadResult with UserRules containing rule with DstPort=0. Assert output does NOT contain `port =` for that rule.
11. TestPrintConfigDefaultAnnotation: Create LoadResult with defaults. Assert output contains `(default)` annotation for ICMP entry.
12. TestPrintConfigOverrideAnnotation: Create LoadResult where ICMP has BaseHz=100.0 (differs from default 65.0). Assert output contains `(override)` for ICMP.
13. TestPrintConfigAutoAssignedAnnotation: Create LoadResult with AutoClasses map containing "GameServer"=true. Assert output contains `(auto-assigned)` for GameServer entry.
</action>
<verify>
<automated>go test ./config/... -v -count=1 -run "PrintConfig" && go test ./... -count=1</automated>
</verify>
<acceptance_criteria>
- config/config.go contains `func PrintConfig(result LoadResult) string`
- config/config.go contains `func waveformString(wt synth.WaveformType) string`
- config/config.go LoadResult struct contains `AutoClasses map[classify.TrafficClass]bool`
- config/config.go PrintConfig output contains `# NetSynth effective configuration`
- config/config.go PrintConfig output contains `# Config source:`
- config/config_test.go contains `TestPrintConfigContainsAllClasses`
- config/config_test.go contains `TestPrintConfigSourcePath`
- config/config_test.go contains `TestPrintConfigContainsRules`
- config/config_test.go contains `TestPrintConfigDefaultAnnotation`
- config/config_test.go contains `TestPrintConfigOverrideAnnotation`
- config/config_test.go contains `TestPrintConfigAutoAssignedAnnotation`
- `go test ./... -count=1` exits 0 (full suite green)
</acceptance_criteria>
<done>
PrintConfig produces commented TOML output with: header (source path, date), [[rules]] section for user rules (port omitted when 0), [sounds.*] section for all classes in deterministic order. Each sound entry annotated as (default), (override), or (auto-assigned). Full test suite green including all existing tests.
</done>
</task>
</tasks>
<verification>
- `go test ./... -count=1` -- full suite green
- `go vet ./...` -- no issues
- `netsynth --print-config` outputs commented TOML to stdout (manual check)
- `netsynth --print-config --config <file>` shows overrides annotated as such
</verification>
<success_criteria>
- --print-config flag works without -i, outputs to stdout, exits without capture
- User rules prepended before DefaultRules in both live and pcap modes
- PrintConfig output contains all 14 built-in classes plus any user-defined classes
- Comment annotations correctly distinguish default / override / auto-assigned
- Source path shown in header when config loaded
- [[rules]] section present in output when user rules exist
- Full go test suite passes
</success_criteria>
<output>
After completion, create `.planning/phases/07-custom-rules-and-print-config/07-02-SUMMARY.md`
</output>
@@ -0,0 +1,95 @@
---
phase: 07-custom-rules-and-print-config
plan: "02"
subsystem: config, cmd/netsynth
tags: [config, cli, print-config, rules, wiring, CFG-06, RULE-02]
dependency_graph:
requires: [LoadResult, UserRules, AutoClasses, PrintConfig]
provides: [--print-config flag, runPrintConfig, user-rule-prepend, PrintConfig-output]
affects: [cmd/netsynth/main.go, config/config.go]
tech_stack:
added: ["sort", "time", "strings.Builder"]
patterns: [LoadResult-propagation, user-rule-prepend, annotated-TOML-output]
key_files:
created: []
modified:
- cmd/netsynth/main.go
- cmd/netsynth/main_test.go
- config/config.go
- config/config_test.go
decisions:
- "--print-config check placed after --list-interfaces but before interface-required validation so it works without -i"
- "AutoClasses map added to LoadResult to track which classes were auto-assigned by FNV-32a"
- "PrintConfig returns a string (not writes to io.Writer) for testability; caller prints to stdout"
- "waveformString returns custom for WaveformCustom (zero value used by hand-tuned built-in classes)"
- "classAnnotation: built-in classes compared on both BaseHz and WaveformType for override detection"
metrics:
duration: 8min
completed: "2026-03-26T20:55:00Z"
tasks_completed: 2
files_modified: 4
---
# Phase 7 Plan 02: CLI Wiring and PrintConfig Output Summary
Wire the LoadResult into main.go (user rules prepend, --print-config flag), implement the PrintConfig output function in the config package, and add comprehensive tests for both. Completes RULE-02 and CFG-06 — the final plan for Phase 7 and the v1.1 milestone.
## What Was Built
**cmd/netsynth/main.go:**
- Added `printConfig bool` var and `--print-config` flag registration
- `runPrintConfig()`: calls `config.Load(configPath)` then `config.PrintConfig(result)`, prints to stdout, exits clean
- --print-config check fires before interface-required validation (no -i needed)
- `runLiveMode` and `runPcapMode` now accept `config.LoadResult` instead of bare `map[TrafficClass]FreqConfig`
- User rules prepend in both modes: `append(result.UserRules, classify.DefaultRules...)` (RULE-02)
- Removed unused `synth` import
**config/config.go:**
- `LoadResult` gains `AutoClasses map[classify.TrafficClass]bool` field
- `addAutoFreqEntries` updated to accept and populate `autoClasses` map
- `Load()` initializes `AutoClasses` map and returns it in `LoadResult`
- `PrintConfig(result LoadResult) string`: generates commented TOML output with:
- Header: `# NetSynth effective configuration`, `# Config source: <path or "none (using defaults)">`, `# Generated: <UTC timestamp>`
- `[[rules]]` section for each user rule (port omitted when DstPort==0)
- `[sounds.*]` section for all classes in deterministic order (14 built-ins in AllClasses() order, then user-defined sorted alphabetically)
- Per-class annotation: `(default)`, `(override)`, or `(auto-assigned)`
- `waveformString()`: converts WaveformType to TOML string
- `classAnnotation()`: determines annotation based on AutoClasses membership and comparison with defaults
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Wire LoadResult into main.go and add --print-config flag | d43914f | cmd/netsynth/main.go, cmd/netsynth/main_test.go |
| 2 | Implement PrintConfig output function with comment annotations | b52e36b | config/config.go, config/config_test.go |
## Deviations from Plan
None - plan executed exactly as written.
## Test Coverage
New tests added (8 PrintConfig tests in config_test.go, 3 print-config tests in main_test.go):
**config/config_test.go:**
- `TestPrintConfigContainsAllClasses` - all 14 class names in output
- `TestPrintConfigSourcePath` - `# Config source: <path>` in header
- `TestPrintConfigNoSourcePath` - `# Config source: none` when no config
- `TestPrintConfigContainsRules` - `[[rules]]` section with port/protocol/class
- `TestPrintConfigRuleNoPort` - port line omitted when DstPort==0
- `TestPrintConfigDefaultAnnotation` - `(default)` for unmodified built-in class
- `TestPrintConfigOverrideAnnotation` - `(override)` for modified built-in class
- `TestPrintConfigAutoAssignedAnnotation` - `(auto-assigned)` for FNV-hash assigned class
**cmd/netsynth/main_test.go:**
- `TestPrintConfigFlagRegistered` - flag exists on command
- `TestPrintConfigNoInterface` - --print-config works without -i
- `TestPrintConfigWithConfigFile` - --print-config with --config succeeds
Full suite: `go test ./... -count=1` all 7 packages pass.
## Known Stubs
None. All functionality is fully implemented and wired.
## Self-Check: PASSED
@@ -0,0 +1,123 @@
# Phase 7: Custom Rules and Print-Config - Context
**Gathered:** 2026-03-26
**Status:** Ready for planning
<domain>
## Phase Boundary
Add user-defined traffic classification rules in TOML (`[[rules]]` array-of-tables) that prepend before built-in rules, with automatic synthesis layer creation for new class names. Add `--print-config` flag that outputs the full effective config as commented TOML to stdout without starting a capture.
Requirements covered: RULE-01, RULE-02, RULE-03, CFG-06.
</domain>
<decisions>
## Implementation Decisions
### Custom Rule TOML Schema
- **D-01:** Custom rules use TOML array-of-tables `[[rules]]` with three fields: `port` (uint16, optional — omit to match any port), `protocol` (string, required — "tcp", "udp", or "icmp"), and `class` (string, required — the TrafficClass name). Sound configuration for the class goes in a separate `[sounds.<class>]` block.
- **D-02:** Port is optional. When omitted (or 0), the rule matches all traffic for the given protocol, mirroring the existing `Rule.DstPort = 0` semantics in `classify.DefaultRules`.
- **D-03:** Protocol is required. No implicit "match both TCP and UDP" behavior. User must write separate rules for each protocol.
### Rule Ordering and Priority
- **D-04:** User-defined rules are prepended before built-in `DefaultRules` (RULE-02). First-match-wins semantics are preserved. A user rule for port 443/tcp fires before the built-in HTTPS rule.
- **D-05:** Rules within the TOML `[[rules]]` array maintain their file order. First rule in the file is first to match.
### Class Name Collision Policy
- **D-06:** User-defined class names that match built-in names (e.g., `class = "HTTPS"`) are treated as overrides, not errors. The user's rule fires first (prepended), so traffic matching it gets classified under the same built-in class name via the user rule. Sound config in `[sounds.HTTPS]` still applies. This resolves the design question flagged in STATE.md.
### Sound Assignment for Custom Classes
- **D-07:** New class names that have no `[sounds.<class>]` entry automatically get sensible defaults: a frequency from an unused range and sine waveform. This satisfies RULE-03 (no silent gaps for user-defined classes).
- **D-08:** (Claude's Discretion) The auto-assignment algorithm — how to pick frequencies for new classes that don't collide with built-in frequencies. Could use a hash of the class name, a sequential pool, or a deterministic spread across an unused frequency band.
### Print-Config
- **D-09:** `--print-config` outputs the full effective config (defaults merged with user overrides and custom rules) as commented TOML. Comments indicate which values are defaults vs overrides. This satisfies CFG-06.
- **D-10:** Output goes to stdout (pipeable). User can do `netsynth --print-config > template.toml` to create a config template. The command exits without starting a capture.
- **D-11:** If a config file is loaded (via auto-discovery or `--config`), show its source path in a header comment.
### Config Package Extension
- **D-12:** The existing `config.Load()` function must be extended to parse `[[rules]]` blocks in addition to `[sounds.*]`. The `rawConfig` struct gains a `Rules []RawRule` field.
- **D-13:** `config.Load()` returns both the merged `FreqConfig` map and the user rules (as `[]classify.Rule`). The caller prepends user rules before `classify.DefaultRules`.
### Claude's Discretion
- How to extend `rawConfig` struct and `Load()` return type (tuple, struct, or new function)
- Auto-frequency assignment algorithm for custom classes without explicit sound config
- Whether `--print-config` is a Cobra subcommand or a flag on the root command
- How to format the commented TOML output (manual string building vs TOML encoder + post-processing)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Classification System
- `classify/rules.go``Rule` struct (Protocol, DstPort, Class), `DefaultRules` ordered slice, first-match-wins
- `classify/types.go``TrafficClass` string type, `AllClasses()`, `ClassifiedPacket`, `WindowSnapshot`
- `classify/classifier.go``NewClassifier(rules []Rule)` — accepts injected rule slice
### Config System (Phase 6 output)
- `config/config.go``Load()`, `rawConfig`, `SoundOverride`, `merge()`, `validate()`, `parseWaveform()`
- `config/config_test.go` — Existing test patterns for TOML loading
### Synthesis Pipeline
- `synth/config.go``FreqConfig`, `ClassFreqConfigs`, `WaveformType`, `WaveformPresetHarmonics()`
- `synth/bank.go``NewBank(tau, cfgs map[TrafficClass]FreqConfig)` — injection point for merged config
- `encode/mp3.go``RunSynthesis(snapshots, outputPath, freqCfgs)` — pipeline entry
### CLI
- `cmd/netsynth/main.go` — Cobra command, `--config` flag, `run()` dispatches to live/pcap modes
### Prior Context
- `.planning/phases/06-config-package-and-sound-overrides/06-CONTEXT.md` — Phase 6 decisions (TOML schema, merge semantics, validation)
No external specs — requirements fully captured in decisions above.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `classify.Rule` struct — Already has Protocol, DstPort, Class fields matching the TOML schema
- `classify.NewClassifier(rules []Rule)` — Accepts any rule slice, so prepending user rules is straightforward
- `config.Load()` — Existing TOML loading with BurntSushi/toml, validation, and merge pipeline
- `config.rawConfig` — Top-level decode struct, needs `Rules` field added
- `config.parseWaveform()` — Reusable for validating waveform strings in sound overrides
- `synth.NewBank(tau, cfgs)` — Already accepts arbitrary config maps (Phase 5 injection seam)
### Established Patterns
- First-match-wins rule ordering in `classify.DefaultRules`
- TOML strict decoding with `Undecoded()` for unknown key detection
- Pointer fields (`*float64`, `*string`) for partial override semantics
- Config loaded once at startup before capture (fail-fast)
### Integration Points
- `config.Load()` return value must expand to include user rules
- `cmd/netsynth/main.go:run()` — Prepend user rules before passing to `classify.NewClassifier()`
- `config.merge()` — Must handle new class names by creating `FreqConfig` entries with auto-assigned frequencies
- `--print-config` — New flag or subcommand in Cobra root command
</code_context>
<specifics>
## Specific Ideas
- Print-config should show commented TOML with `# default` / `# override` annotations and source path header
- Output to stdout so users can pipe to a file as a template
</specifics>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope.
</deferred>
---
*Phase: 07-custom-rules-and-print-config*
*Context gathered: 2026-03-26*
@@ -0,0 +1,73 @@
# Phase 7: Custom Rules and Print-Config - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-03-26
**Phase:** 07-custom-rules-and-print-config
**Areas discussed:** Custom rule TOML schema, Class name collision policy, Print-config output format, Sound assignment for custom classes
---
## Custom Rule TOML Schema
| Option | Description | Selected |
|--------|-------------|----------|
| Minimal: port + protocol + class | Matches existing Rule struct. Sound config in separate [sounds.X]. | ✓ |
| Inline sound: port + protocol + class + frequency/waveform | All-in-one rule block, mixes classification and sound concerns. | |
| Rich matching: port ranges, src/dst, regex | More expressive but significantly more complex. | |
**User's choice:** Minimal — port + protocol + class
**Notes:** Protocol is required. Port is optional (omit to match any port for the protocol).
---
## Class Name Collision Policy
| Option | Description | Selected |
|--------|-------------|----------|
| Treat as override | User's rule fires first (prepended), same class name. Simplest model. | ✓ |
| Reject with error | Startup error if user rule uses built-in class name. | |
| Namespace: prefix user classes | User classes get "user-" prefix. Adds naming complexity. | |
**User's choice:** Treat as override
**Notes:** Resolves the design question flagged in STATE.md since Phase 5 research.
---
## Print-Config Output Format
| Option | Description | Selected |
|--------|-------------|----------|
| Commented TOML | Valid TOML with comments showing default vs override. Pipeable to file. | ✓ |
| Plain TOML | Clean but doesn't show what's default vs overridden. | |
| Human-readable table | Formatted table, not valid TOML. | |
**User's choice:** Commented TOML to stdout
**Notes:** Output to stdout so `netsynth --print-config > template.toml` works. Shows source path in header comment.
---
## Sound Assignment for Custom Classes
| Option | Description | Selected |
|--------|-------------|----------|
| Auto-assign sensible defaults | Pick unused frequency + sine waveform. No silence. | ✓ |
| Require explicit [sounds.X] | Error if no matching sound config. More friction. | |
| Single fallback tone | All custom classes share one tone. Defeats distinct sounds purpose. | |
**User's choice:** Auto-assign sensible defaults
**Notes:** Satisfies RULE-03 (no silent gaps). Algorithm left to Claude's discretion.
---
## Claude's Discretion
- Auto-frequency assignment algorithm
- --print-config as flag vs subcommand
- Commented TOML formatting approach
- config.Load() return type extension
## Deferred Ideas
None — discussion stayed within phase scope.
@@ -0,0 +1,515 @@
# Phase 7: Custom Rules and Print-Config - Research
**Researched:** 2026-03-26
**Domain:** Go config parsing (BurntSushi/toml), rule system extension, TOML serialization
**Confidence:** HIGH
## Summary
Phase 7 adds two related features: user-defined TOML classification rules that prepend before built-in `DefaultRules`, and a `--print-config` flag that serializes the full effective config to stdout as commented TOML without starting a capture. Both features are pure Go additions with no new dependencies — the entire implementation works within the existing stack.
The rule parsing extension is straightforward: add a `Rules []RawRule` field to `rawConfig`, validate required fields (`protocol`, `class`), and prepend the parsed `[]classify.Rule` slice before `classify.DefaultRules` in `main.go`. BurntSushi/toml's `Undecoded()` mechanism already catches typos in `[[rules]]` blocks (verified experimentally — unknown fields in array-of-table entries appear in `Undecoded()` as `rules.field_name`). Auto-frequency assignment for new class names uses FNV-32a hash of the class name mapped to a 12002400 Hz range (above all 14 built-in frequencies which top out at 1047 Hz), producing deterministic and collision-resistant results.
The `--print-config` implementation has two design paths: Cobra flag on the root command (simpler, consistent with the existing flag-on-root pattern) or a Cobra subcommand. The flag path is recommended as it mirrors how `--list-interfaces` works. The output format uses manual string building (not the TOML encoder) to support `# default` / `# override` annotations that the encoder cannot produce.
**Primary recommendation:** Extend `config.Load()` to return a `LoadResult` struct (freqCfgs + user rules), prepend user rules in `main.go`, use FNV-32a for auto-frequency assignment, and implement `--print-config` as a flag that triggers early-exit in the `run()` function.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Custom rules use TOML array-of-tables `[[rules]]` with three fields: `port` (uint16, optional — omit to match any port), `protocol` (string, required — "tcp", "udp", or "icmp"), and `class` (string, required — the TrafficClass name). Sound configuration for the class goes in a separate `[sounds.<class>]` block.
- **D-02:** Port is optional. When omitted (or 0), the rule matches all traffic for the given protocol, mirroring the existing `Rule.DstPort = 0` semantics in `classify.DefaultRules`.
- **D-03:** Protocol is required. No implicit "match both TCP and UDP" behavior. User must write separate rules for each protocol.
- **D-04:** User-defined rules are prepended before built-in `DefaultRules` (RULE-02). First-match-wins semantics are preserved. A user rule for port 443/tcp fires before the built-in HTTPS rule.
- **D-05:** Rules within the TOML `[[rules]]` array maintain their file order. First rule in the file is first to match.
- **D-06:** User-defined class names that match built-in names (e.g., `class = "HTTPS"`) are treated as overrides, not errors. The user's rule fires first (prepended), so traffic matching it gets classified under the same built-in class name via the user rule. Sound config in `[sounds.HTTPS]` still applies.
- **D-07:** New class names that have no `[sounds.<class>]` entry automatically get sensible defaults: a frequency from an unused range and sine waveform. This satisfies RULE-03 (no silent gaps for user-defined classes).
- **D-08:** (Claude's Discretion) The auto-assignment algorithm — how to pick frequencies for new classes that don't collide with built-in frequencies. Could use a hash of the class name, a sequential pool, or a deterministic spread across an unused frequency band.
- **D-09:** `--print-config` outputs the full effective config (defaults merged with user overrides and custom rules) as commented TOML. Comments indicate which values are defaults vs overrides. This satisfies CFG-06.
- **D-10:** Output goes to stdout (pipeable). User can do `netsynth --print-config > template.toml` to create a config template. The command exits without starting a capture.
- **D-11:** If a config file is loaded (via auto-discovery or `--config`), show its source path in a header comment.
- **D-12:** The existing `config.Load()` function must be extended to parse `[[rules]]` blocks in addition to `[sounds.*]`. The `rawConfig` struct gains a `Rules []RawRule` field.
- **D-13:** `config.Load()` returns both the merged `FreqConfig` map and the user rules (as `[]classify.Rule`). The caller prepends user rules before `classify.DefaultRules`.
### Claude's Discretion
- How to extend `rawConfig` struct and `Load()` return type (tuple, struct, or new function)
- Auto-frequency assignment algorithm for custom classes without explicit sound config
- Whether `--print-config` is a Cobra subcommand or a flag on the root command
- How to format the commented TOML output (manual string building vs TOML encoder + post-processing)
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| RULE-01 | User can define custom classification rules in TOML (match by port and/or protocol, assign class name and sound) | D-01 through D-05; `[[rules]]` TOML parsing with BurntSushi/toml confirmed working |
| RULE-02 | User-defined rules take priority over built-in rules (prepend before defaults) | D-04; `classify.NewClassifier(rules []Rule)` already accepts any rule slice; prepend in `main.go` |
| RULE-03 | User-defined class names automatically get a synthesis layer (no silent gaps) | D-07/D-08; FNV-32a auto-frequency in 12002400 Hz range; `synth.NewBank` already accepts arbitrary class maps |
| CFG-06 | User can run `netsynth --print-config` to see the effective config as commented TOML | D-09 through D-11; flag on root command triggering early-exit; manual string building for comment annotations |
</phase_requirements>
## Standard Stack
No new dependencies required. All features use existing libraries.
### Core (existing, no changes needed)
| Library | Version | Purpose | Notes |
|---------|---------|---------|-------|
| `github.com/BurntSushi/toml` | v1.6.0 | TOML decode + `Undecoded()` typo detection | Handles `[[rules]]` array-of-tables natively; `Undecoded()` catches typos in rule blocks |
| `github.com/spf13/cobra` | v1.10.2 | `--print-config` flag addition | PersistentPreRunE / early-exit pattern already used; add flag to root command |
| `hash/fnv` | stdlib | FNV-32a hash for auto-frequency assignment | No import needed — already in Go stdlib |
| `fmt` | stdlib | Manual TOML comment string building for print-config | Simplest approach for annotated output |
**Installation:** No new packages. `go build` with existing `go.mod` is sufficient.
## Architecture Patterns
### Recommended Project Structure (additions only)
```
config/
├── config.go — extend rawConfig + Load() return type
└── config_test.go — add tests for [[rules]] parsing, validate, auto-freq
cmd/netsynth/
└── main.go — --print-config flag, prepend user rules, printConfig()
```
### Pattern 1: rawConfig + Load() Return Type Extension
**What:** Add `Rules []RawRule` to `rawConfig`. Change `Load()` to return a `LoadResult` struct instead of a bare map.
**When to use:** Prefer a struct return over a tuple `(map, []Rule, error)` — Go tuples with 3+ values become unwieldy at the call site.
**Recommended struct:**
```go
// Source: internal design — no external library required
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string // "" if no config loaded (for --print-config header comment)
}
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
```
**Call site in main.go:**
```go
result, err := config.Load(configPath)
if err != nil {
return err
}
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
```
### Pattern 2: RawRule Validation
**What:** Validate each `RawRule` before converting to `classify.Rule`. Required fields: `protocol` (non-empty, must be "tcp"/"udp"/"icmp"). `class` must be non-empty. `port` is optional.
**Port omission semantics:** In TOML, a missing `port` key means the struct field stays at its zero value. Using `*uint16` (pointer) lets us distinguish "omitted" from "port = 0". In practice, both map to `DstPort: 0` (match-any-port), so a `uint16` field (non-pointer) also works here — the distinction is only meaningful for validation messages. Use `*uint16` to match D-02 intent and for consistency with `SoundOverride` pointer fields.
**Validation function:**
```go
func validateRules(rules []RawRule) error {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
for i, r := range rules {
if r.Protocol == "" {
return fmt.Errorf("config: rules[%d]: protocol is required", i)
}
if !validProtocols[r.Protocol] {
return fmt.Errorf("config: rules[%d]: invalid protocol %q — valid: tcp, udp, icmp", i, r.Protocol)
}
if r.Class == "" {
return fmt.Errorf("config: rules[%d]: class is required", i)
}
}
return nil
}
```
**Conversion to classify.Rule:**
```go
func convertRules(raw []RawRule) []classify.Rule {
result := make([]classify.Rule, len(raw))
for i, r := range raw {
var port uint16
if r.Port != nil {
port = *r.Port
}
result[i] = classify.Rule{
Protocol: r.Protocol,
DstPort: port,
Class: classify.TrafficClass(r.Class),
}
}
return result
}
```
### Pattern 3: Auto-Frequency Assignment via FNV-32a
**What:** For custom class names with no `[sounds.<class>]` block, assign a frequency deterministically from the class name using FNV-32a hash. Map to 12002400 Hz in 50 Hz steps.
**Why FNV-32a:** Fast, deterministic, already in stdlib, zero collisions observed across realistic class names. The 12002400 Hz range is entirely above the highest built-in frequency (1047 Hz for ClassUnknown4), so no overlap is possible.
**Verified with test:** The 24-step spread (1200, 1250, ..., 2350 Hz) gives clean frequency assignments: "MyApp"→1500 Hz, "GameServer"→1800 Hz, "MediaStream"→2150 Hz, "VoIP"→1750 Hz, "Database"→2000 Hz.
```go
// Source: stdlib hash/fnv — no import required beyond "hash/fnv"
import "hash/fnv"
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = 24 // range: 12002350 Hz
)
step := h.Sum32() % numSteps
return baseHz + float64(step)*stepHz
}
```
**Integration point in `merge()`:** After processing all `Sounds` overrides, iterate user rules. For each rule whose `Class` is not in the defaults map and has no `[sounds.<class>]` entry, call `autoAssignFreq` and create a `FreqConfig` with `WaveformSine`.
```go
// In config.merge() or a new mergeCustomClasses() helper:
func addAutoFreqEntries(
cfgs map[classify.TrafficClass]synth.FreqConfig,
userRules []classify.Rule,
) {
for _, rule := range userRules {
class := rule.Class
if _, exists := cfgs[class]; !exists {
baseHz := autoAssignFreq(string(class))
cfgs[class] = synth.FreqConfig{
BaseHz: baseHz,
WaveformType: synth.WaveformSine,
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0,
}
}
}
}
```
### Pattern 4: --print-config as Flag on Root Command
**What:** Add `--print-config` bool flag. In `run()`, check the flag early and call a `printConfig()` function that writes to stdout, then return nil without starting a capture.
**Why flag over subcommand:** Consistent with `--list-interfaces` (existing pattern). Both are "inspect mode" flags that short-circuit the main capture path. Subcommand would require the user to write `netsynth print-config` rather than `netsynth --print-config`, deviating from the established CLI style.
```go
// In main():
var printConfigFlag bool
rootCmd.Flags().BoolVar(&printConfigFlag, "print-config", false, "Print effective config as commented TOML and exit")
// In run():
if printConfigFlag {
return runPrintConfig(configPath)
}
```
**runPrintConfig() structure:**
```go
func runPrintConfig(configPath string) error {
result, err := config.Load(configPath)
if err != nil {
return err
}
output, err := config.PrintConfig(result)
if err != nil {
return err
}
fmt.Print(output)
return nil
}
```
### Pattern 5: PrintConfig Output Format
**What:** Manual string building using `fmt.Fprintf` to a `strings.Builder`. The BurntSushi/toml encoder cannot add comments, so manual building is the correct approach.
**Output structure:**
```toml
# NetSynth effective configuration
# Config source: /home/user/.config/netsynth/config.toml
# Generated: 2026-03-26
# Custom classification rules (prepended before built-in rules)
# [[rules]]
# port = 8080
# protocol = "tcp"
# class = "MyApp"
[sounds]
# ICMP — 65.0 Hz (default)
[sounds.ICMP]
frequency = 65.0
waveform = "custom"
# HTTPS — 300.0 Hz (override)
[sounds.HTTPS]
frequency = 300.0
waveform = "sine"
```
**Comment semantics:**
- `# (default)` — value came from `synth.ClassFreqConfigs`
- `# (override)` — value was set in the user's config file
- `# (auto-assigned)` — frequency was auto-generated for a user-defined class
**Key implementation note:** Custom class entries added by `addAutoFreqEntries()` need their origin tracked. The `LoadResult` or a separate annotation map needs to carry which classes are auto-assigned vs user-overridden vs defaults. Simplest approach: `PrintConfig()` receives the `LoadResult` and compares against `synth.ClassFreqConfigs` to determine annotation.
### Pattern 6: Undecoded() and [[rules]] Interaction
**Verified behavior (experimental):** BurntSushi/toml's `Undecoded()` correctly catches unknown fields in `[[rules]]` blocks. A typo like `typo_field = "bad"` in a `[[rules]]` entry appears in `Undecoded()` as `rules.typo_field`. The existing `parseFile()` logic handles this automatically — no changes to the undecoded key check are needed.
**Important:** Class-name typos in `[sounds.<class>]` are STILL not caught (pre-existing limitation documented in the existing code comment). This is unchanged behavior for Phase 7.
### Anti-Patterns to Avoid
- **Returning a tuple `(map, []Rule, error)` from Load():** Three-value tuples at call sites are verbose and error-prone. Use a `LoadResult` struct.
- **Using the TOML encoder for print-config output:** The encoder cannot add `# (default)` comments. Manual `fmt.Fprintf` to `strings.Builder` is the correct approach.
- **Modifying `classify.DefaultRules` in place:** Always prepend user rules as a new slice. `DefaultRules` is a package-level var that must not be mutated. Use `append(userRules, classify.DefaultRules...)` to create a fresh slice.
- **Silent auto-frequency collision:** If two user-defined classes hash to the same frequency step, they will produce the same tone. This is acceptable for v1.1 (the probability is low with 24 steps) but document it in comments.
- **Placing `--print-config` check after config validation:** The check should occur early in `run()` — right after config load, before any interface or output validation. The user should be able to run `netsynth --print-config` without specifying `-i`.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| TOML parsing with typo detection | Custom parser | BurntSushi/toml `Undecoded()` | Already used; handles `[[rules]]` natively |
| Frequency hash | Custom hash function | stdlib `hash/fnv` FNV-32a | Zero-dependency, deterministic, already stdlib |
| CLI flag parsing | Manual arg parsing | Cobra flag registration | Consistent with all existing flags |
**Key insight:** Every mechanism needed for Phase 7 already exists in the codebase. The risk is over-engineering: the entire implementation is struct extension + prepend + string building.
## Common Pitfalls
### Pitfall 1: Mutating classify.DefaultRules
**What goes wrong:** `append(classify.DefaultRules, userRules...)` prepends to the wrong end and may mutate the backing array of `DefaultRules` if the slice has capacity.
**Why it happens:** Go slice append behavior with shared backing arrays.
**How to avoid:** Always build the combined slice as `append(userRules, classify.DefaultRules...)` — user rules first, then defaults. This also gives the correct prepend order (D-04).
**Warning signs:** Built-in rules fire before user rules for the same port/protocol.
### Pitfall 2: --print-config Requires -i (incorrect)
**What goes wrong:** The `run()` function checks for `-i` / `--read` before checking `--print-config`, causing `netsynth --print-config` to fail with "interface required".
**Why it happens:** The interface-required validation runs before the print-config check.
**How to avoid:** Check `printConfigFlag` at the very top of `run()`, before the interface validation block. This matches how `listIfaces` is handled.
**Warning signs:** `netsynth --print-config` returns "interface required" error instead of config output.
### Pitfall 3: Undecoded() Reports sounds.<class>.frequency as Unknown
**What goes wrong:** After adding `Rules []RawRule` to `rawConfig`, the `Undecoded()` check may report `sounds.MyApp.frequency` as unknown if the `SoundOverride` struct is not correctly decoded.
**Why it happens:** This was a concern during research but was verified NOT to occur — `[sounds.MyApp]` with a `SoundOverride` struct value decodes correctly alongside `[[rules]]`. No issue exists.
**How to avoid:** N/A — verified working. Document in code that both sections coexist correctly.
### Pitfall 4: Auto-Frequency Called for Built-in Class Names
**What goes wrong:** If a user writes `class = "HTTPS"` in `[[rules]]`, `addAutoFreqEntries()` must not overwrite the existing HTTPS entry with an auto-generated frequency.
**Why it happens:** The auto-assign loop checks `if _, exists := cfgs[class]; !exists` — built-in classes ARE in the defaults map, so this guard works correctly. But only if `addAutoFreqEntries()` runs AFTER `merge()` has already applied `[sounds.*]` overrides.
**How to avoid:** Call `addAutoFreqEntries()` as the last step in the merge pipeline, after `merge(defaults, raw.Sounds)`. The class-exists check then correctly skips both built-in and user-overridden classes.
### Pitfall 5: print-config Missing User Rules Section
**What goes wrong:** `printConfig()` shows `[sounds.*]` entries but omits `[[rules]]` entries, making the output not round-trippable.
**Why it happens:** Developer focuses on the sounds section (the existing config domain) and forgets rules.
**How to avoid:** The `LoadResult` must include both `UserRules []classify.Rule` and `ConfigPath string`. The `PrintConfig()` function must emit the `[[rules]]` section before the `[sounds.*]` section, using the user rule slice.
### Pitfall 6: validate() Must Run Before convertRules()
**What goes wrong:** An empty `protocol` or `class` field in `[[rules]]` gets silently converted to a `classify.Rule` with empty strings, producing confusing runtime behavior.
**Why it happens:** `convertRules()` has no validation; it just copies fields.
**How to avoid:** Call `validateRules(raw.Rules)` inside `validate()` (the existing validation entry point), before conversion. Fail fast at startup.
## Code Examples
### TOML File with Custom Rules (user-facing format)
```toml
# Classify custom app traffic on port 8080
[[rules]]
port = 8080
protocol = "tcp"
class = "MyApp"
# Match all UDP traffic to a custom class (port omitted = match any)
[[rules]]
protocol = "udp"
class = "AllUDP"
# Give MyApp a custom sound
[sounds.MyApp]
frequency = 300.0
waveform = "sine"
# Override built-in HTTPS sound
[sounds.HTTPS]
frequency = 400.0
```
### rawConfig Extension
```go
// config/config.go
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
```
### LoadResult Struct (replaces bare map return)
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string // populated path or "" if auto-discovery found nothing
}
func Load(configPath string) (LoadResult, error) { ... }
```
### Prepend User Rules in main.go
```go
result, err := config.Load(configPath)
if err != nil {
return err
}
// D-04: user rules prepend before built-ins; first-match-wins
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
```
### FNV-32a Auto-Frequency
```go
import "hash/fnv"
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = uint32(24)
)
return baseHz + float64(h.Sum32()%numSteps)*stepHz
}
```
## State of the Art
No changes to the underlying technology stack. All patterns are internal to the codebase.
| Old Behavior | New Behavior | When Changed | Impact |
|---|---|---|---|
| `config.Load()` returns `map[classify.TrafficClass]synth.FreqConfig` | Returns `LoadResult` struct with FreqCfgs + UserRules + ConfigPath | Phase 7 | All callers of `Load()` need update (currently 1 call site: `main.go:run()`) |
| Unknown class names in `[sounds.*]` silently ignored (warning) | Still ignored with warning, but user-defined class names from `[[rules]]` get auto-freq entries instead | Phase 7 | New behavior for Phase 7 class names; old warning behavior preserved for truly unknown names |
| `merge()` only processes sound overrides | `merge()` + `addAutoFreqEntries()` also handles new class synthesis entries | Phase 7 | Synthesis bank grows dynamically with user-defined classes |
## Environment Availability
Step 2.6: SKIPPED (no external dependencies identified — Phase 7 is a pure Go code extension using existing stack).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go standard `testing` package |
| Config file | None (no pytest.ini / jest.config equivalent) |
| Quick run command | `go test ./config/... ./cmd/netsynth/...` |
| Full suite command | `go test ./...` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| RULE-01 | `[[rules]]` block in TOML parses correctly into `RawRule` slice | unit | `go test ./config/... -run TestLoadCustomRules` | ❌ Wave 0 |
| RULE-01 | Port field omitted → DstPort 0 (match-any) | unit | `go test ./config/... -run TestLoadCustomRuleNoPort` | ❌ Wave 0 |
| RULE-01 | Missing `protocol` field → error | unit | `go test ./config/... -run TestLoadCustomRuleMissingProtocol` | ❌ Wave 0 |
| RULE-01 | Missing `class` field → error | unit | `go test ./config/... -run TestLoadCustomRuleMissingClass` | ❌ Wave 0 |
| RULE-01 | Typo in `[[rules]]` field → error naming bad key | unit | `go test ./config/... -run TestLoadCustomRuleUnknownField` | ❌ Wave 0 |
| RULE-02 | User rule for port 443/tcp fires before built-in HTTPS rule | unit | `go test ./config/... -run TestUserRulesPrepend` | ❌ Wave 0 |
| RULE-03 | Class name with no `[sounds.*]` entry gets auto-freq entry in FreqCfgs | unit | `go test ./config/... -run TestAutoFreqAssignment` | ❌ Wave 0 |
| RULE-03 | Auto-freq is deterministic (same class name → same frequency) | unit | `go test ./config/... -run TestAutoFreqDeterministic` | ❌ Wave 0 |
| RULE-03 | Built-in class names NOT overwritten by auto-freq | unit | `go test ./config/... -run TestAutoFreqSkipsBuiltins` | ❌ Wave 0 |
| CFG-06 | `--print-config` flag registered on root command | unit | `go test ./cmd/netsynth/... -run TestPrintConfigFlagRegistered` | ❌ Wave 0 |
| CFG-06 | `--print-config` exits without requiring `-i` flag | unit | `go test ./cmd/netsynth/... -run TestPrintConfigNoInterface` | ❌ Wave 0 |
| CFG-06 | Print-config output contains all 14 default class names | unit | `go test ./config/... -run TestPrintConfigContainsAllClasses` | ❌ Wave 0 |
| CFG-06 | Print-config output contains `[[rules]]` section when user rules are present | unit | `go test ./config/... -run TestPrintConfigContainsRules` | ❌ Wave 0 |
| CFG-06 | Print-config includes source path in header comment when config loaded | unit | `go test ./config/... -run TestPrintConfigSourcePath` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `go test ./config/... ./cmd/netsynth/...`
- **Per wave merge:** `go test ./...`
- **Phase gate:** `go test ./...` green before `/gsd:verify-work`
### Wave 0 Gaps
All test functions listed above are new — no existing test file covers Phase 7 behavior. Tests should be added to:
- `config/config_test.go` — all `config` package tests (follow existing `writeTOML` helper pattern)
- `cmd/netsynth/main_test.go` — all `cmd/netsynth` flag tests (follow existing `newTestCmd()` pattern)
No new test files needed — extend the existing test files.
## Open Questions
1. **PrintConfig annotation tracking for auto-assigned classes**
- What we know: `addAutoFreqEntries()` adds entries to `FreqCfgs` for new class names
- What's unclear: `PrintConfig()` needs to know which entries are auto-assigned (vs default vs user-override) to annotate them correctly
- Recommendation: Add an `AutoClasses map[classify.TrafficClass]bool` field to `LoadResult`, populated by `addAutoFreqEntries()`. `PrintConfig()` consults this map.
2. **Cobra --print-config placement: before or after config load**
- What we know: `--print-config` needs config loaded to show effective values
- What's unclear: What if the user runs `netsynth --print-config` with no config file?
- Recommendation: Always call `config.Load(configPath)` before printing. If no config file is found (auto-discovery returns nothing), the output shows all defaults — which is the most useful behavior.
## Sources
### Primary (HIGH confidence)
- Source code: `config/config.go` — read directly; rawConfig struct, Load(), merge(), validate(), parseFile() all verified
- Source code: `classify/rules.go` — DefaultRules slice, Rule struct verified
- Source code: `classify/classifier.go` — NewClassifier(rules []Rule) injection point verified
- Source code: `synth/bank.go` — NewBank(tau, cfgs) accepts arbitrary class maps confirmed
- Source code: `synth/config.go` — ClassFreqConfigs frequency range 651047 Hz confirmed; WaveformPresetHarmonics verified
- Source code: `cmd/netsynth/main.go` — current Load() call site; listIfaces early-exit pattern verified
- Experimental: BurntSushi/toml `Undecoded()` behavior with `[[rules]]` — verified by running test code against go.mod-pinned v1.6.0
- Experimental: FNV-32a frequency distribution — verified by running Go code; 24 distinct frequencies in 12002400 Hz range
### Secondary (MEDIUM confidence)
- Source code: `config/config_test.go` — test patterns for writeTOML, Load() behavior; test style confirmed
- Source code: `cmd/netsynth/main_test.go` — newTestCmd() pattern, flag registration test style confirmed
### Tertiary (LOW confidence)
- None.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies; all libraries verified against go.mod
- Architecture: HIGH — all integration points verified by reading actual source code
- Pitfalls: HIGH — Pitfalls 1, 2, 4, 5 verified by code inspection; Pitfall 3 experimentally verified as non-issue
- Test patterns: HIGH — existing test file structure read directly
**Research date:** 2026-03-26
**Valid until:** Stable — 90 days (pure Go, no external dependencies, stable TOML library)
@@ -0,0 +1,86 @@
---
phase: 7
slug: custom-rules-and-print-config
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-26
---
# Phase 7 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go standard `testing` package |
| **Config file** | None |
| **Quick run command** | `go test ./config/... ./cmd/netsynth/...` |
| **Full suite command** | `go test ./...` |
| **Estimated runtime** | ~5 seconds |
---
## Sampling Rate
- **After every task commit:** Run `go test ./config/... ./cmd/netsynth/...`
- **After every plan wave:** Run `go test ./...`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 5 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 07-01-01 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRules` | ❌ W0 | ⬜ pending |
| 07-01-02 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleNoPort` | ❌ W0 | ⬜ pending |
| 07-01-03 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleMissingProtocol` | ❌ W0 | ⬜ pending |
| 07-01-04 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleMissingClass` | ❌ W0 | ⬜ pending |
| 07-01-05 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleUnknownField` | ❌ W0 | ⬜ pending |
| 07-01-06 | 01 | 1 | RULE-02 | unit | `go test ./config/... -run TestUserRulesPrepend` | ❌ W0 | ⬜ pending |
| 07-01-07 | 01 | 1 | RULE-03 | unit | `go test ./config/... -run TestAutoFreqAssignment` | ❌ W0 | ⬜ pending |
| 07-01-08 | 01 | 1 | RULE-03 | unit | `go test ./config/... -run TestAutoFreqDeterministic` | ❌ W0 | ⬜ pending |
| 07-01-09 | 01 | 1 | RULE-03 | unit | `go test ./config/... -run TestAutoFreqSkipsBuiltins` | ❌ W0 | ⬜ pending |
| 07-02-01 | 02 | 2 | CFG-06 | unit | `go test ./cmd/netsynth/... -run TestPrintConfigFlagRegistered` | ❌ W0 | ⬜ pending |
| 07-02-02 | 02 | 2 | CFG-06 | unit | `go test ./cmd/netsynth/... -run TestPrintConfigNoInterface` | ❌ W0 | ⬜ pending |
| 07-02-03 | 02 | 2 | CFG-06 | unit | `go test ./config/... -run TestPrintConfigContainsAllClasses` | ❌ W0 | ⬜ pending |
| 07-02-04 | 02 | 2 | CFG-06 | unit | `go test ./config/... -run TestPrintConfigContainsRules` | ❌ W0 | ⬜ pending |
| 07-02-05 | 02 | 2 | CFG-06 | unit | `go test ./config/... -run TestPrintConfigSourcePath` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `config/config_test.go` — add test stubs for RULE-01, RULE-02, RULE-03 (extend existing file using `writeTOML` helper pattern)
- [ ] `cmd/netsynth/main_test.go` — add test stubs for CFG-06 (extend existing file using `newTestCmd()` pattern)
*Existing infrastructure covers framework install — `go test` already works.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Custom rule produces distinct tone in MP3 output | RULE-01 | Audio output requires human ear verification | Run `netsynth -i lo --config test.toml -o out.mp3`, listen for distinct tone on custom rule port |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 5s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
@@ -0,0 +1,115 @@
---
phase: 07-custom-rules-and-print-config
verified: 2026-03-26T21:10:00Z
status: passed
score: 10/10 must-haves verified
---
# Phase 7: Custom Rules and Print-Config Verification Report
**Phase Goal:** Users can define their own traffic classification rules in TOML, assign custom sounds to them, and inspect the full effective config before capture begins
**Verified:** 2026-03-26T21:10:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|---------|
| 1 | TOML `[[rules]]` blocks parse into `classify.Rule` slices | VERIFIED | `convertRules()` in config.go:192-206; `TestLoadCustomRules` passes; `TestLoadCustomRuleNoPort` passes |
| 2 | Missing protocol or class in a rule produces a clear error at startup | VERIFIED | `validateRules()` in config.go:175-189; `TestLoadCustomRuleMissingProtocol`, `TestLoadCustomRuleMissingClass`, `TestLoadCustomRuleInvalidProtocol` all pass |
| 3 | User rules are returned separately from FreqCfgs for caller to prepend | VERIFIED | `LoadResult.UserRules []classify.Rule` field in config.go:48-52; `TestUserRulesPrepend` passes |
| 4 | New class names without explicit sound config get auto-assigned frequencies in 1200-2400 Hz range | VERIFIED | `autoAssignFreq()` in config.go:210-219 uses FNV-32a; range [1200, 2350]; `TestAutoFreqAssignment` and `TestAutoFreqDeterministic` pass |
| 5 | Built-in class names in user rules do not get overwritten by auto-freq | VERIFIED | `addAutoFreqEntries()` checks `if _, exists := cfgs[rule.Class]; !exists` before assigning; `TestAutoFreqSkipsBuiltins` verifies HTTPS stays at 175.0 Hz |
| 6 | User runs `netsynth --print-config` and sees full effective config as commented TOML on stdout without capture starting | VERIFIED | `runPrintConfig()` in main.go:114-122; `if printConfig` check at main.go:73 fires before interface-required validation; `TestPrintConfigNoInterface` passes |
| 7 | User rules prepend before built-in rules so first-match-wins gives user priority | VERIFIED | `append(result.UserRules, classify.DefaultRules...)` in both `runLiveMode` (main.go:139) and `runPcapMode` (main.go:205); `TestUserRulesPrepend` confirms prepend order |
| 8 | Print-config output shows source path when config file loaded | VERIFIED | `PrintConfig()` emits `# Config source: <path>` when `result.ConfigPath != ""`; `TestPrintConfigSourcePath` passes |
| 9 | Print-config output annotates defaults vs overrides vs auto-assigned | VERIFIED | `classAnnotation()` in config.go:340-353 returns "default", "override", or "auto-assigned"; `TestPrintConfigDefaultAnnotation`, `TestPrintConfigOverrideAnnotation`, `TestPrintConfigAutoAssignedAnnotation` all pass |
| 10 | Print-config output includes `[[rules]]` section when user rules are present | VERIFIED | `PrintConfig()` emits `[[rules]]` section when `len(result.UserRules) > 0` (config.go:284-295); `TestPrintConfigContainsRules` passes |
**Score:** 10/10 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `config/config.go` | RawRule, LoadResult, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries, PrintConfig | VERIFIED | All 7 constructs present; file is 396 lines, fully substantive |
| `config/config_test.go` | Tests for rule parsing, validation, auto-freq, LoadResult, PrintConfig | VERIFIED | 29 tests total (8 pre-existing + 13 Plan-01 + 8 Plan-02); all pass |
| `cmd/netsynth/main.go` | --print-config flag, runPrintConfig(), user rule prepend | VERIFIED | Flag registered at main.go:49; runPrintConfig at main.go:114; prepend in both runLiveMode and runPcapMode |
| `cmd/netsynth/main_test.go` | Tests for --print-config flag | VERIFIED | TestPrintConfigFlagRegistered, TestPrintConfigNoInterface, TestPrintConfigWithConfigFile all present and pass |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| config/config.go | classify/rules.go | convertRules produces []classify.Rule | WIRED | `classify.Rule` used at lines 49, 78, 192-206, 225 — manual grep confirmed |
| config/config.go | synth/config.go | autoAssignFreq creates FreqConfig entries with WaveformPresetHarmonics | WIRED | `synth.WaveformPresetHarmonics` called at lines 232, 384, 390 — manual grep confirmed |
| cmd/netsynth/main.go | config/config.go | runPrintConfig calls config.Load then config.PrintConfig | WIRED | `config.PrintConfig(result)` at main.go:119 — manual grep confirmed |
| cmd/netsynth/main.go | classify/rules.go | append(result.UserRules, classify.DefaultRules...) | WIRED | gsd-tools verified; pattern present at main.go:139 and main.go:205 |
Note: gsd-tools key-link checker reported false negatives for the three pattern matches involving escaped dots (`\.`). All four links are confirmed present via manual grep.
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| config/config.go PrintConfig | result.UserRules, result.FreqCfgs, result.AutoClasses | config.Load() parsing TOML + FNV-32a hash | Yes — real TOML parsing, classify.Rule slices, synth.FreqConfig map | FLOWING |
| cmd/netsynth/main.go runLiveMode | allRules via result.UserRules | config.Load() -> convertRules() -> user TOML | Yes — user rules prepended to classify.DefaultRules | FLOWING |
| cmd/netsynth/main.go runPcapMode | allRules via result.UserRules | config.Load() -> convertRules() -> user TOML | Yes — same prepend pattern as runLiveMode | FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| config package: all 29 tests pass | `go test ./config/... -count=1` | ok (0.017s) | PASS |
| cmd/netsynth package: all 13 tests pass | `go test ./cmd/netsynth/... -count=1` | ok (0.013s) | PASS |
| Full test suite: all 7 packages | `go test ./... -count=1` | ok all 7 packages | PASS |
| Static analysis | `go vet ./...` | no issues | PASS |
| PrintConfig includes all 14 class names | TestPrintConfigContainsAllClasses | PASS | PASS |
| --print-config works without -i | TestPrintConfigNoInterface | PASS | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|---------|
| RULE-01 | 07-01 | User can define custom classification rules in TOML (match by port and/or protocol, assign class name) | SATISFIED | `RawRule` struct + `rawConfig.Rules []RawRule` + TOML `[[rules]]` parsing; TestLoadCustomRules and TestLoadCustomRuleNoPort verify parsing |
| RULE-02 | 07-01, 07-02 | User-defined rules take priority over built-in rules (prepend before defaults) | SATISFIED | `append(result.UserRules, classify.DefaultRules...)` in both runLiveMode and runPcapMode; TestUserRulesPrepend verifies order |
| RULE-03 | 07-01 | User-defined class names automatically get a synthesis layer (no silent gaps) | SATISFIED | `addAutoFreqEntries()` creates FreqConfig for unknown class names using FNV-32a in [1200, 2350] Hz; TestAutoFreqAssignment verifies entry exists with WaveformSine |
| CFG-06 | 07-02 | User can run `netsynth --print-config` to see the effective config as commented TOML | SATISFIED | `--print-config` flag registered; `runPrintConfig()` calls config.Load + config.PrintConfig + fmt.Print; fires before interface-required check; TestPrintConfigNoInterface confirms no -i needed |
**Orphaned requirements:** None. All four requirement IDs (RULE-01, RULE-02, RULE-03, CFG-06) are claimed by plan frontmatter and verified above. REQUIREMENTS.md traceability table confirms all four map to Phase 7.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (none) | — | — | — | — |
No TODOs, FIXMEs, placeholder comments, empty return stubs, or hardcoded empty data found in modified files. The merge() function at config.go:368 does contain `return defaults` but returns the populated map after in-place mutation — this is correct behavior, not a stub.
### Human Verification Required
The following behaviors cannot be verified programmatically and require manual testing before production use:
#### 1. End-to-End TOML Round-Trip
**Test:** Create a `netsynth.toml` with multiple `[[rules]]` blocks (different ports, protocols, class names), run `netsynth --print-config`, copy the output to a new file, and load it again with `--print-config --config <copied-file>`.
**Expected:** Output from both invocations should show the same class frequencies and annotations.
**Why human:** Requires file creation, CLI invocation, and comparison of two output streams — not suitable for automated spot-check in a non-interactive environment.
#### 2. Custom Rule Sound Differentiation
**Test:** Create a TOML defining a custom rule for port 8080/tcp as "WebApp", run a capture or play a pcap with HTTP traffic on port 8080, and listen to the resulting MP3.
**Expected:** Port 8080 traffic should produce a distinct tone from port 80 (HTTP) traffic.
**Why human:** Requires audio playback and subjective listening — cannot be verified programmatically.
### Gaps Summary
No gaps. All 10 observable truths are verified, all 4 artifacts pass all three levels (exists, substantive, wired), all 4 key links are confirmed present in the code, all 4 requirements are satisfied, and the full test suite (7 packages, 29+ config tests, 13 main tests) passes cleanly.
---
_Verified: 2026-03-26T21:10:00Z_
_Verifier: Claude (gsd-verifier)_
+303 -271
View File
@@ -1,344 +1,376 @@
# Architecture Research
# Architecture Patterns
**Domain:** Network traffic sonification CLI (Go)
**Researched:** 2026-03-24
**Confidence:** MEDIUM — Go audio synthesis patterns verified via official docs and real libraries; sonification architecture inferred from academic literature (SoNSTAR) and Go concurrency canon.
**Domain:** Network traffic sonification CLI (Go) — v1.1 Custom Sound Mappings
**Researched:** 2026-03-26
**Confidence:** HIGH — based on direct code inspection of the existing v1.0 codebase
## Standard Architecture
---
### System Overview
## v1.1 Integration Overview
This document supersedes the pre-implementation v1.0 architecture research. It is grounded in the actual codebase (3,254 lines, 6 packages) and answers: what changes, what's new, and in what order.
---
## Existing Package Map (v1.0 Baseline)
```
┌─────────────────────────────────────────────────────────────┐
│ CLI Entry Point │
│ (flags: interface, output path, duration) │
└───────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Capture Layer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ PacketSource (gopacket/pcap) │ │
│ │ Produces: chan Packet │ │
│ └──────────────────────┬──────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────────┘
│ raw packet stream
┌─────────────────────────────────────────────────────────────┐
│ Classification Layer │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Protocol Classifier │ │ Unknown Traffic │ │
│ │ (ICMP, DNS, HTTPS, │ │ Clusterer │ │
│ │ SSH, TCP-other, UDP) │ │ (feature-based bucketer)│ │
│ └───────────┬─────────────┘ └────────────┬────────────┘ │
│ └──────────────┬──────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
│ classified packet events
┌─────────────────────────────────────────────────────────────┐
│ Aggregation Layer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Time-Window Accumulator │ │
│ │ - fixed window (e.g. 500ms) │ │
│ │ - counts + byte-volume per traffic class │ │
│ │ Produces: chan WindowSnapshot │ │
│ └──────────────────────┬──────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────────┘
│ window snapshots
┌─────────────────────────────────────────────────────────────┐
│ Synthesis Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Layer 0 │ │ Layer 1 │ │ Layer N │ │ Layer X │ │
│ │ (ICMP) │ │ (DNS) │ │ (HTTPS) │ │ (auto) │ │
│ │ Osc+Amp │ │ Osc+Amp │ │ Osc+Amp │ │ Osc+Amp │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────────────┴────────────┴──────────────┘ │
│ │ │
│ ┌─────▼──────┐ │
│ │ Mixer │ │
│ │ (sum+clip) │ │
│ └─────┬──────┘ │
└──────────────────────────┼──────────────────────────────────┘
│ PCM sample stream (float32[])
┌─────────────────────────────────────────────────────────────┐
│ Encoding Layer │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ PCM Buffer Accumulator → LAME MP3 Encoder │ │
│ │ (go-lame / CGo libmp3lame) │ │
│ └──────────────────────┬──────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────────┘
│ .mp3 file
Output File
cmd/netsynth/main.go CLI, pipeline wiring, Cobra flags
capture/ go-pcap live capture + pcap file reader + BPF
classify/
types.go TrafficClass, ClassifiedPacket, WindowSnapshot
classifier.go NewClassifier(rules []Rule) — first-match-wins
rules.go DefaultRules []Rule (12 hardcoded rules)
aggregate/
window.go 500ms time-windowed snapshot accumulation
synth/
config.go ClassFreqConfigs — fixed map[TrafficClass]FreqConfig
oscillator.go Phase-accumulator oscillator — sine only
layer.go EMA amplitude smoothing per layer
bank.go NewBank(tau) — one Layer per AllClasses()
mixer.go PanGains, StereoFramesToInt16Bytes
encode/
mp3.go RunSynthesis(snapshots, path) — NewBank + EncodeMP3
```
### Component Responsibilities
---
| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| CLI Entry | Parse flags, wire all components, handle Ctrl+C via `os.Signal` | `main.go`, `cobra` or `flag` package |
| PacketSource | Open interface via pcap/AF_PACKET, emit packets into channel | `gopacket.PacketSource.Packets()``<-chan gopacket.Packet` |
| Protocol Classifier | Inspect decoded layers (IP, TCP, UDP, ICMP, DNS); assign class label | Pure Go switch on `packet.Layer()` type assertions |
| Unknown Traffic Clusterer | Hash or bucket unclassified flows by port range / packet size signature; assign stable label ID | Simple feature-hash bucketer; no heavy ML needed for v1 |
| Time-Window Accumulator | Batch packets into N-ms windows; emit packet-count and byte-volume per class | `ticker`-driven goroutine, map accumulation |
| Sound Layer (per class) | Maintain a sine oscillator at a fixed root frequency; update amplitude from window snapshot | Oscillator struct with phase accumulator; amplitude lerp |
| Mixer | Sum all layer outputs sample-by-sample; clamp/normalize to [-1, 1] | Simple additive sum with soft clip |
| MP3 Encoder | Accept PCM float32 frames; encode to MP3 on flush/stop | go-lame (CGo) or pure-Go fallback |
| Output File | Write encoded bytes to disk path from CLI flag | `os.File` + buffered writer |
## What v1.1 Adds
## Recommended Project Structure
Three independent but related features:
1. **TOML config file** — override frequencies and waveforms per built-in class
2. **Additional waveforms** — square, sawtooth, triangle alongside existing sine
3. **User-defined classification rules** — TOML-defined rules prepended before DefaultRules
---
## Integration Point Analysis
### Feature 1: TOML Config File
**Where config is consumed today:** `synth/config.go` holds a package-level `var ClassFreqConfigs`. `synth/bank.go:NewBank()` reads it directly with `ClassFreqConfigs[class]`. No config is passed through `encode.RunSynthesis` or `main.go`.
**Required change:** `NewBank` must accept a config parameter instead of reading the global. `encode.RunSynthesis` must accept and forward a config. `main.go` must load config from disk and pass it in.
**New package: `config/`**
This package does not exist yet in the codebase (the pre-implementation research anticipated it but it was deferred). It should own:
- TOML struct definitions
- File discovery logic (auto-detect `./netsynth.toml`, then `~/.config/netsynth/config.toml`)
- Merging: loaded config overlays defaults, does not replace them entirely
```
netsynth/
├── main.go # CLI wiring, signal handling, top-level orchestration
├── capture/
│ └── capture.go # PacketSource wrapper, interface open/close, chan Packet
├── classify/
│ ├── classifier.go # Protocol dispatch, class label assignment
│ └── cluster.go # Unknown traffic bucketer (feature hash)
├── aggregate/
│ └── window.go # Time-window accumulator, WindowSnapshot type
├── synth/
│ ├── oscillator.go # Phase-accumulator sine oscillator
│ ├── layer.go # Per-traffic-class sound layer (osc + amp target)
│ └── mixer.go # Sum layers → float32 PCM frames
├── encode/
│ └── mp3.go # PCM → MP3 via go-lame; file flush on close
└── config/
└── mapping.go # Protocol → frequency/harmonic assignment table
config/
config.go Config struct, Load(path string) (*Config, error)
defaults.go DefaultConfig() — wraps existing ClassFreqConfigs values
```
### Structure Rationale
**TOML struct shape:**
- **capture/:** Isolates pcap/root-privilege boundary. Everything above it operates on typed Go channels with no pcap dependency.
- **classify/:** Cleanly separates rule-based (known protocol) from heuristic (unknown cluster) logic. Each can be tested with synthetic packet fixtures independently.
- **aggregate/:** The only stateful time-domain component. Isolating it makes window size configurable without touching synthesis.
- **synth/:** Pure PCM math — no I/O, no pcap. Fully unit-testable with deterministic inputs. The mixer owns the sample rate constant.
- **encode/:** CGo boundary lives here and nowhere else. If LAME is replaced (e.g., pure Go encoder), only this package changes.
- **config/:** Static frequency-to-protocol table. Separating it avoids magic numbers scattered across synth/.
```toml
[[class]]
name = "HTTPS"
frequency_hz = 200.0
waveform = "sawtooth"
## Architectural Patterns
[[class]]
name = "myservice" # user-defined class (Feature 3)
frequency_hz = 350.0
waveform = "triangle"
```
### Pattern 1: Channel-Connected Pipeline Stages
The `Config` struct passed into `NewBank` should merge with `ClassFreqConfigs`:
**What:** Each component is a goroutine that reads from an inbound channel and writes to an outbound channel. The `done` channel (closed on Ctrl+C) signals all stages to drain and exit cleanly.
**When to use:** Always — this is the idiomatic Go pipeline pattern described in the Go Blog.
**Trade-offs:** Slightly more setup than direct function calls; pays off immediately with clean shutdown and testability of individual stages.
**Example:**
```go
// Each stage signature follows this pattern
func Classify(done <-chan struct{}, packets <-chan gopacket.Packet) <-chan ClassifiedPacket {
out := make(chan ClassifiedPacket, 256)
go func() {
defer close(out)
for {
select {
case <-done:
return
case pkt, ok := <-packets:
if !ok { return }
out <- classify(pkt)
}
}
}()
return out
// config/config.go
type ClassConfig struct {
Name string `toml:"name"`
FrequencyHz float64 `toml:"frequency_hz"`
Waveform string `toml:"waveform"` // "sine" | "square" | "sawtooth" | "triangle"
}
type Config struct {
Classes []ClassConfig `toml:"class"`
Rules []RuleConfig `toml:"rule"` // Feature 3
}
```
### Pattern 2: Ticker-Driven Window Flush
**TOML library:** Use `github.com/BurntSushi/toml`. It is the de-facto standard for TOML in Go (used by Hugo, dep, buf, etc.). Already a transitive dependency in many Go module graphs. Provides struct-tag-based decode, good error messages.
**What:** The aggregation goroutine owns a `time.Ticker`. On each tick it snapshots accumulated counters and sends a `WindowSnapshot` downstream, then resets counters.
---
**When to use:** Anywhere time-based batching converts a high-frequency stream into low-frequency control signals.
### Feature 2: Additional Waveforms
**Trade-offs:** Fixed window size (e.g. 500ms) is simple but loses sub-window dynamics. Sliding windows add complexity with marginal benefit for ambient synthesis.
**Where waveform logic lives today:** `synth/oscillator.go:Advance()` — pure sine via `math.Sin`. The `HarmonicDef.Ratio` and `HarmonicDef.Amplitude` fields are stored in `FreqConfig.Harmonics` but the waveform function is hardcoded.
**Required change:** `Oscillator.Advance` must dispatch on a waveform type. Two clean approaches:
**Option A (recommended): Waveform enum on Oscillator**
Add a `waveform` field to `Oscillator`. `Advance` switches on it. `NewOscillator` gains a waveform parameter.
**Example:**
```go
func Aggregate(done <-chan struct{}, events <-chan ClassifiedPacket, windowMs int) <-chan WindowSnapshot {
out := make(chan WindowSnapshot, 8)
ticker := time.NewTicker(time.Duration(windowMs) * time.Millisecond)
go func() {
defer close(out)
counts := map[TrafficClass]int{}
for {
select {
case <-done:
return
case <-ticker.C:
out <- snapshot(counts)
counts = map[TrafficClass]int{}
case ev, ok := <-events:
if !ok { return }
counts[ev.Class]++
type Waveform int
const (
WaveformSine Waveform = iota
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
type Oscillator struct {
phase float64
freq float64
sr float64
waveform Waveform
}
func (o *Oscillator) sampleAt(phase, ratio float64) float64 {
p := phase * float64(ratio)
p -= math.Floor(p) // wrap to [0, 1)
switch o.waveform {
case WaveformSquare:
if p < 0.5 { return 1.0 }
return -1.0
case WaveformSawtooth:
return 2.0*p - 1.0
case WaveformTriangle:
if p < 0.5 { return 4.0*p - 1.0 }
return 3.0 - 4.0*p
default: // WaveformSine
return math.Sin(2 * math.Pi * p)
}
}
}()
return out
}
```
### Pattern 3: Per-Layer Amplitude Lerp
**Option B: Function field on Oscillator**
**What:** Each sound layer holds a current amplitude and a target amplitude. On each audio frame the current value moves toward the target by a smoothing coefficient. The layer's oscillator always runs; silence is achieved by targeting amplitude = 0.
Store `waveFn func(phase float64) float64`. More flexible but harder to serialize/configure.
**When to use:** Whenever window snapshots drive synthesis — avoids clicks/pops from abrupt amplitude changes.
Option A is preferred because waveform type maps cleanly to the TOML `waveform` string field without reflection tricks.
**Trade-offs:** Adds minimal CPU overhead (one multiply per frame per layer); necessary for perceptually smooth audio.
**`FreqConfig` change:** Add `Waveform` field:
## Data Flow
### Primary Flow: Packets to PCM
```
Network Interface
▼ (gopacket pcap handle)
PacketSource.Packets() chan
▼ (classify goroutine)
ClassifiedPacket chan
▼ (aggregate goroutine, ticker)
WindowSnapshot chan ─────────────────────────────────┐
(synth goroutine,
per window snap:
update amplitude targets)
PCM frame generator loop
(renders N frames per window,
one frame = sum of all layers)
PCM []float32 blocks
LAME encoder (streaming)
MP3 bytes → output file
```go
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
Waveform Waveform // NEW: defaults to WaveformSine
}
```
### Shutdown Flow
`NewLayer` passes `cfg.Waveform` to `NewOscillator`. `NewOscillator` signature changes to accept the waveform.
```
Ctrl+C → os.Signal → close(done) channel
├── capture goroutine: drain + close packet chan
├── classify goroutine: drain + close event chan
├── aggregate goroutine: drain + close snapshot chan
└── synth goroutine: flush remaining PCM → encoder.Flush() → file.Close()
**What does NOT change:** `HarmonicDef`, `EMAAlpha`, `Layer.UpdateTarget`, `Layer.AdvanceSample`, `OscillatorBank.RenderWindow`, `mixer.go`, `encode/mp3.go`. The waveform change is contained to `oscillator.go` and the `FreqConfig` struct.
---
### Feature 3: User-Defined Classification Rules
**Where rules are wired today:** `main.go` lines 111, 175 — both `runLiveMode` and `runPcapMode` call `classify.NewClassifier(classify.DefaultRules)` directly. No config is passed.
**Required change:** User rules from TOML prepend before `DefaultRules`. `Classifier` already supports arbitrary `[]Rule``NewClassifier(rules []Rule)` is the constructor. No change to `classifier.go` itself.
**`RuleConfig` TOML struct:**
```toml
[[rule]]
protocol = "tcp"
dst_port = 8443
class = "myservice"
```
### Key Data Types
1. **`gopacket.Packet`** → raw decoded packet from pcap; carries layer stack.
2. **`ClassifiedPacket{Packet, Class TrafficClass, Bytes int}`** → labeled event.
3. **`WindowSnapshot{ClassCounts map[TrafficClass]int, ClassBytes map[TrafficClass]int}`** → per-window aggregate; drives amplitude targets.
4. **`[]float32` PCM block** → mixer output at 44100 Hz, mono; flows into LAME.
## Build Order (Phase Implications)
Build in dependency order — each layer is independently testable before the next is added:
```
1. capture/ → can test: "does it open an interface and emit packets?"
2. classify/ → can test: "does ICMP get labeled ICMP?" (synthetic packets)
3. aggregate/ → can test: "does a 500ms window count correctly?"
4. synth/ → can test: "does mixer output expected amplitude?" (no pcap needed)
5. encode/ → can test: "does PCM produce valid MP3 bytes?"
6. main.go wiring → integration: full end-to-end pipeline
```go
// config/config.go
type RuleConfig struct {
Protocol string `toml:"protocol"`
DstPort uint16 `toml:"dst_port"`
Class string `toml:"class"` // must match a name in [[class]] or a builtin class name
}
```
This ordering means:
- **Phase 1** can deliver a working capture + classify pipeline writing JSON/text summaries — validating the hardest privilege/pcap risk early.
- **Phase 2** delivers the synthesis engine in isolation — testable with synthetic `WindowSnapshot` inputs before any real traffic.
- **Phase 3** wires them together with the MP3 encoder.
**Merging in main.go:**
## Anti-Patterns
```go
userRules := config.ToClassifyRules(cfg.Rules) // []classify.Rule
allRules := append(userRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
```
### Anti-Pattern 1: Synchronous Per-Packet Audio Rendering
**New `TrafficClass` values:** User-defined classes in TOML produce new `TrafficClass` string values (e.g., `"myservice"`). `AllClasses()` in `classify/types.go` is currently a hardcoded slice. For user-defined classes, `AllClasses()` cannot be the source of truth for bank layer construction. `NewBank` must instead iterate over whatever classes have a `FreqConfig` entry.
**What people do:** Generate one audio sample or tone event per packet — a 10 Gbps link produces 14M packets/sec, making synchronous render impossible.
This is a critical integration point: `bank.go:NewBank` currently ranges over `classify.AllClasses()`. If user classes can appear, `NewBank` must accept the full config map and range over that instead.
**Why it's wrong:** Breaks at any real traffic volume; produces click-heavy output, not smooth drone.
---
**Do this instead:** Batch packets into time windows (500ms1s) and drive amplitude targets from the batch, not individual packets.
## New vs Modified Components
### Anti-Pattern 2: Blocking Channel Sends in the Capture Path
### New
**What people do:** Use unbuffered channels between PacketSource and classifier; slow classifier stalls the pcap ring buffer and causes kernel drops.
| Component | Location | Purpose |
|-----------|----------|---------|
| `config` package | `config/config.go` | TOML struct, `Load()`, file discovery, merge with defaults |
| `config/defaults.go` | optional split | `DefaultConfig()` wrapping existing `ClassFreqConfigs` values |
**Why it's wrong:** libpcap's kernel buffer is fixed-size; if userspace can't drain it fast enough, packets are silently dropped. For audio purposes this introduces silent gaps.
### Modified
**Do this instead:** Use buffered channels (capacity 2561024) between capture and classify. Drop packets on full buffer with a counter — acceptable for sonification, fatal to log completeness tools.
| Component | Change | Impact |
|-----------|--------|--------|
| `synth/oscillator.go` | Add `Waveform` type + `waveform` field; dispatch in `Advance` | Self-contained; no caller signature breaks except `NewOscillator` |
| `synth/config.go` | Add `Waveform Waveform` field to `FreqConfig`; default to `WaveformSine` | Requires `NewLayer` to pass waveform to `NewOscillator` |
| `synth/layer.go` | Pass `cfg.Waveform` to `NewOscillator` | One-line change |
| `synth/bank.go` | Accept `map[classify.TrafficClass]FreqConfig` param instead of reading global; range over param keys not `AllClasses()` | Decouples bank from global; enables user classes |
| `encode/mp3.go` | Accept `*config.Config` or merged `FreqConfig` map; pass to `NewBank` | Thin forwarding change |
| `cmd/netsynth/main.go` | Add `--config` flag; load config; prepend user rules; pass config to `RunSynthesis` | Touches both `runLiveMode` and `runPcapMode` |
| `classify/rules.go` | No change — `DefaultRules` stays as the fallback | Unchanged |
| `classify/classifier.go` | No change — already accepts `[]Rule` | Unchanged |
| `classify/types.go` | `AllClasses()` may need a note that it returns only builtins; bank no longer relies on it | Low risk; document only |
### Anti-Pattern 3: CGo MP3 Encoding in the Hot Audio Loop
---
**What people do:** Call `lame.Encode()` synchronously inside the frame-render loop, stalling synthesis.
## Data Flow Changes
**Why it's wrong:** CGo calls carry overhead; libmp3lame may block on I/O; this disrupts the synthesis clock.
### v1.0 Flow (config hardcoded)
**Do this instead:** The synth goroutine pushes PCM blocks onto a buffered channel; a separate encoder goroutine drains and encodes. On shutdown, close the PCM channel and drain completely before `lame.Close()`.
```
main.go
└─ classify.NewClassifier(classify.DefaultRules)
└─ encode.RunSynthesis(snapshots, path)
└─ synth.NewBank(1.0)
└─ ClassFreqConfigs[class] ← global, hardcoded
```
### Anti-Pattern 4: Global Mutable State for Class Frequency Mapping
### v1.1 Flow (config injected)
**What people do:** Use a global `map[TrafficClass]float64` for frequency assignments modified at runtime.
```
main.go
└─ config.Load(configPath) ← NEW: resolve path, parse TOML, merge defaults
└─ cfg *config.Config
└─ classify.NewClassifier(
append(config.ToClassifyRules(cfg.Rules), classify.DefaultRules...)
) ← user rules prepend built-ins
└─ encode.RunSynthesis(snapshots, path, cfg.FreqConfigs())
└─ synth.NewBank(1.0, freqConfigs) ← map passed in, not read from global
└─ freqConfigs[class] ← merged: user overrides + defaults
```
**Why it's wrong:** Race conditions; hard to test; makes the mapping invisible to callers.
---
**Do this instead:** Pass the mapping table as an immutable struct at construction time. Auto-clustered classes append to a local slice protected by a mutex inside the clusterer — not a global.
## Suggested Build Order
## Integration Points
The following order minimizes integration risk. Each step is independently testable before the next begins.
### External Services
### Step 1: Waveform types in `synth/oscillator.go`
| Dependency | Integration Pattern | Notes |
|------------|---------------------|-------|
| libpcap / pcap.h | CGo via gopacket/pcap — requires libpcap-dev at build time | Can substitute AF_PACKET (linux only) to avoid CGo in capture; still needs root |
| libmp3lame | CGo via go-lame — requires libmp3lame-dev at build time | Binary distribution requires static linking or Docker; pure-Go MP3 (e.g. oto + gmp3) is an option but quality/speed tradeoff |
No external dependencies. Pure math. Testable with golden-sample unit tests (square wave sample at phase 0.25 should be 1.0, etc.). Does not affect `Layer`, `Bank`, or `encode` yet.
### Internal Boundaries
**Files changed:** `synth/oscillator.go` only.
| Boundary | Communication | Notes |
|----------|---------------|-------|
| capture ↔ classify | `chan gopacket.Packet` (buffered 512) | classify must never block capture |
| classify ↔ aggregate | `chan ClassifiedPacket` (buffered 1024) | aggregate is slower (ticker-driven); buffer absorbs bursts |
| aggregate ↔ synth | `chan WindowSnapshot` (buffered 4) | synth consumes synchronously per window; small buffer is fine |
| synth ↔ encode | `chan []float32` (buffered 8 blocks) | encoder runs in separate goroutine to decouple CGo latency |
| all stages ↔ main | `chan struct{}` done channel | closed on Ctrl+C; all stages select on it |
### Step 2: Wire `Waveform` through `FreqConfig` and `Layer`
## Scaling Considerations
Add `Waveform` to `FreqConfig`. Update `NewLayer` to pass it to `NewOscillator`. `ClassFreqConfigs` entries default to `WaveformSine` (zero value — valid if `WaveformSine = 0`).
This is a single-binary CLI tool, not a distributed service. Scaling concerns are throughput-based:
Existing tests continue to pass without modification since all existing configs use the zero-value waveform.
| Traffic Rate | Architecture Adjustments |
|--------------|--------------------------|
| Home/office (< 10K pps) | Default design handles easily with no tuning |
| Datacenter (100K1M pps) | Increase capture buffer size; consider AF_PACKET with TPACKET_V3 ring buffer instead of pcap; classify goroutine may need fan-out to 24 workers |
| Line-rate 10G (> 5M pps) | Out of scope for v1 ambient audio tool — synthesis granularity at 500ms windows means exact packet-level accuracy is not required |
**Files changed:** `synth/config.go`, `synth/layer.go`.
### Scaling Priorities
### Step 3: Decouple `NewBank` from the global
1. **First bottleneck:** Kernel pcap buffer drops — mitigated by buffered channels and accepting lossy capture (fine for sonification).
2. **Second bottleneck:** CGo encoding latency coupling synthesis clock — mitigated by decoupled encoder goroutine.
Change `NewBank(tau float64)` to `NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig)`. Update `encode/mp3.go:RunSynthesis` to pass `synth.ClassFreqConfigs` as default.
At this point the system is functionally identical to v1.0 but `NewBank` no longer reads a global.
**Files changed:** `synth/bank.go`, `encode/mp3.go`.
### Step 4: `config` package — TOML structs and file discovery
Implement `config.Load()`, file discovery, and the `DefaultConfig()` function that wraps `synth.ClassFreqConfigs`. No TOML parsing yet — start with the struct definitions and the merge logic.
Add `github.com/BurntSushi/toml` dependency (`go get`).
**Files added:** `config/config.go`.
### Step 5: `--config` flag and user rule merging in `main.go`
Wire `config.Load()` into `run()`. Pass user rules to both `runLiveMode` and `runPcapMode`. Pass merged `FreqConfig` map to `RunSynthesis`.
At this point a minimal TOML config (empty file, or `[[rule]]` only) can be validated end-to-end.
**Files changed:** `cmd/netsynth/main.go`.
### Step 6: Custom frequency and waveform overrides in config
Implement the `[[class]]` TOML section parsing. Add `FreqConfigs()` method to `Config` that returns the merged map (user overrides applied over defaults). Write table-driven tests: "TOML sets HTTPS to 200 Hz sawtooth, bank layer for HTTPS uses 200 Hz sawtooth."
**Files changed:** `config/config.go`.
### Step 7: User-defined classes end-to-end
Support `[[class]]` entries with names not in `classify.AllClasses()`. These become new `TrafficClass` values. User `[[rule]]` entries pointing to these classes are prepended to `DefaultRules`. The bank creates layers for all classes in the merged `FreqConfig` map.
This step requires the most cross-package coordination but by this point each piece is already in place.
**Files changed:** `config/config.go`, `cmd/netsynth/main.go` (verification that unknown class names don't panic).
---
## Component Boundaries After v1.1
| Component | Responsibility | Communicates With |
|-----------|---------------|-------------------|
| `config` | TOML parsing, file discovery, merge logic, `DefaultConfig()` | `synth` (FreqConfig type), `classify` (Rule type) |
| `synth/oscillator` | Phase-accumulator for sine/square/sawtooth/triangle | Used by `Layer` |
| `synth/bank` | Accepts freq config map, constructs one `Layer` per entry | `encode` passes config map in |
| `encode` | Receives config map from `main`, passes to `NewBank` | Thin pass-through |
| `cmd/netsynth/main` | Loads config, merges rules, wires all stages | All packages |
| `classify` | Rules engine (unchanged); `DefaultRules` stays as package-level var | `main` constructs with merged rules |
---
## Critical Integration Constraints
### `AllClasses()` Is Not the Source of Truth for Bank Construction
`bank.go` currently iterates `classify.AllClasses()` to construct layers. After v1.1, the bank must iterate the keys of the `FreqConfig` map passed to it. User-defined classes will not appear in `AllClasses()`. If this is not changed, user-defined class packets will be aggregated in `WindowSnapshot.Counts` but have no corresponding layer — they will produce silence and no error.
**Fix:** `NewBank` iterates `maps.Keys(cfgs)` (or equivalent range over the map), not `classify.AllClasses()`.
### Class Name Validation Must Happen at Config Load Time
If a `[[rule]]` references a class name that has no corresponding `[[class]]` entry and is not a builtin, the system will silently mis-classify packets into a layer that doesn't exist. Validate at `config.Load()` time: every class name in `[[rule]]` must resolve to either a builtin `TrafficClass` or a `[[class]]` entry in the same config.
### `encode.RunSynthesis` Signature Change Is a Breaking API Change
`encode.RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string)` will need to accept the config. If any external code (tests, future callers) uses this signature, they will break. Keep the change to a single place and update all call sites in the same commit.
---
## Anti-Patterns to Avoid
### Anti-Pattern: Reading Global `ClassFreqConfigs` from Multiple Places
If `NewBank`, `encode.RunSynthesis`, and config loading all reference the package-level `synth.ClassFreqConfigs`, the merge point becomes ambiguous. The fix (Step 3 above) centralizes config reading to one place: `config.DefaultConfig()` reads from `ClassFreqConfigs` once when building defaults; everything downstream receives the already-merged map.
### Anti-Pattern: Storing `Waveform` as a String Everywhere
Keeping `waveform` as a `string` from TOML all the way into `Oscillator` means every advance call parses or switches on a string. Parse the string to a `Waveform` int type at config-load time. The `Oscillator` field should be a typed `Waveform`, not `string`.
### Anti-Pattern: User Rules Appended After DefaultRules
User rules must **prepend** `DefaultRules`, not append. `DefaultRules` ends with catch-all rules (`DstPort: 0`) that match any TCP or UDP packet. Appending user rules after these catch-alls means they will never be reached.
---
## Sources
- Go Pipeline patterns: [Go Concurrency Patterns: Pipelines and cancellation](https://go.dev/blog/pipelines) — HIGH confidence, official Go blog
- SoNSTAR network sonification architecture: [Sonification of Network Traffic Flow for Monitoring and Situational Awareness, arXiv 1712.07029](https://arxiv.org/abs/1712.07029) — MEDIUM confidence (abstract only accessed)
- gopacket channel API: [gopacket pkg.go.dev](https://pkg.go.dev/github.com/google/gopacket) — HIGH confidence, official package docs
- bleep synthesizer architecture (Go): [GitHub bspaans/bleep](https://github.com/bspaans/bleep) — MEDIUM confidence (README inspection)
- Waveform synthesis PCM patterns in Go: [Audio From Scratch With Go — Dylan Meeus](https://dylanmeeus.github.io/posts/audio-from-scratch-pt8/) — MEDIUM confidence
- go-lame MP3 encoding: [go-lame pkg.go.dev](https://pkg.go.dev/github.com/sunicy/go-lame) — MEDIUM confidence
- Drone amplitude/frequency modulation patterns: [Drone auralization model, Acta Acustica 2024](https://acta-acustica.edpsciences.org/articles/aacus/full_html/2024/01/aacus240076/aacus240076.html) — MEDIUM confidence
- Direct code inspection: `synth/config.go`, `synth/oscillator.go`, `synth/bank.go`, `synth/layer.go`, `classify/classifier.go`, `classify/rules.go`, `classify/types.go`, `encode/mp3.go`, `cmd/netsynth/main.go` — HIGH confidence
- BurntSushi/toml usage in production Go projects (Hugo, dep): MEDIUM confidence (well-known in Go ecosystem)
- Phase-accumulator waveform synthesis formulas (square, sawtooth, triangle): HIGH confidence (standard DSP, textbook formulas)
---
*Architecture research for: NetSynth — network-traffic-to-audio synthesis CLI (Go)*
*Researched: 2026-03-24*
*Architecture research for: NetSynth v1.1 — custom sound mappings integration*
*Researched: 2026-03-26*
+232 -137
View File
@@ -1,184 +1,279 @@
# Feature Research
**Domain:** Network traffic sonification CLI tool (packet capture ambient MP3)
**Researched:** 2026-03-24
**Confidence:** MEDIUM — this is a niche domain; most comparable tools are research prototypes or GUI applications, not CLI tools. Table stakes are derived from tcpdump/packet-capture CLI conventions and sonification research literature.
**Domain:** Network traffic sonification CLI tool (packet capture -> ambient MP3)
**Researched:** 2026-03-24 (v1.0), updated 2026-03-26 (v1.1 custom sound mappings)
**Confidence:** MEDIUM — niche domain; comparable tools are research prototypes or GUI applications, not CLI tools. Table stakes are derived from tcpdump/packet-capture CLI conventions and sonification research literature.
---
## Feature Landscape
## v1.1 Feature Research: Custom Sound Mappings via TOML Config
### Table Stakes (Users Expect These)
This section addresses the milestone question: "How do custom sound mapping config files typically work in audio/network tools? What are expected behaviors for config file loading, merging with defaults, validation, and error reporting?"
Features users assume exist. Missing these = product feels incomplete.
### Config File Loading: Standard Behaviors Expected by CLI Users
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Network interface selection (`-i eth0`) | tcpdump/tshark convention; every capture tool has this | LOW | `gopacket` exposes interface list; needs `--list-interfaces` companion flag |
| Output file path flag (`-o output.mp3`) | Any file-producing CLI must let you name the output | LOW | Sensible default (e.g. `netsynth-<timestamp>.mp3`) reduces friction |
| Graceful Ctrl+C capture stop with file save | Users expect the tool to cleanly finalize the MP3 on interrupt | MEDIUM | Need signal handler; partial synthesis must be flushed to encoder before exit |
| Per-protocol sound distinction | Core value prop: ping sounds different from HTTPS noise | MEDIUM | Minimum recognizable set: ICMP, DNS, TCP (port 443), TCP (other), UDP |
| Packet count / traffic summary on exit | Every capture tool prints capture statistics; users want to know what was heard | LOW | Print to stderr so it doesn't interfere with stdout pipeline use |
| Privilege error message | `pcap` silently fails or panics without root/CAP_NET_RAW; users need a clear message | LOW | Detect EACCES / EPERM on open; print actionable message (`sudo` or capability hint) |
| List available interfaces (`--list-interfaces`) | Users don't know interface names on unfamiliar machines | LOW | Wrap `pcap.FindAllDevs()`; print name + description |
| Minimum viable duration guard | Zero-packet capture should not produce a corrupt/empty MP3 | LOW | Check sample count before encoding; exit with clear error if nothing was captured |
Based on patterns from established CLI tools (git, golangci-lint, mise, hugo), users expect:
### Differentiators (Competitive Advantage)
1. **Auto-discovery with a defined search order.** The tool looks in a conventional set of locations without requiring an explicit flag. Failing silently (no config found = run with defaults) is correct behavior.
Features that set the product apart. Not required, but valuable.
2. **Explicit override via a flag.** `--config` (or `-c`) lets users point at a non-standard path. If `--config` is supplied and the file does not exist, that is an error — not silent fallback.
3. **Discovery search order (standard precedence):**
- `--config path/to/file.toml` (explicit flag, highest priority)
- `./netsynth.toml` (working directory, project-local)
- `$XDG_CONFIG_HOME/netsynth/config.toml` (defaults to `~/.config/netsynth/config.toml`)
- No config found → run with all defaults (not an error)
This is the pattern used by git (`.git/config` -> `~/.gitconfig` -> `/etc/gitconfig`), golangci-lint (`.golangci.yml` in working dir), and mise (`mise.toml` -> `~/.config/mise/config.toml`). **HIGH confidence** — XDG Base Directory Specification is the Linux/macOS standard.
4. **Partial overrides only — not a replacement config.** The config file expresses only what the user wants to change. Absent keys retain default values. This is universally expected: users do not want to replicate the full default table in order to change one frequency.
### Config File Merging: How Defaults and User Config Combine
The dominant pattern across well-designed CLI tools:
**Merge strategy: user values override defaults, defaults fill gaps.**
```
builtin defaults <-- loaded first (in-code, always present)
+
user config file <-- loaded second (overrides per-key)
=
effective config <-- what the program runs with
```
For NetSynth's classification rules specifically, there are two distinct semantics that must be clearly chosen:
- **Override by name:** User supplies a `[rule.DNS]` block that replaces the built-in DNS sound parameters. The predefined DNS rule's classification logic is kept; only its sound output changes.
- **Prepend user rules:** User-defined rules are inserted before the built-in rule list, allowing them to match first (first-match-wins). This enables the user to add entirely new protocol-to-sound mappings.
Both are needed. They serve different use cases:
- Sound overrides (change frequency/waveform for a known protocol) use the override-by-name pattern.
- Custom traffic rules (classify "tcp port 8443 as MyApp") use prepend semantics.
### Validation: What Users Expect When Config Has Errors
Based on patterns in go-toml v2's strict mode and golangci-lint error reporting:
**Expected validation behaviors (roughly in order of importance):**
| Behavior | Why Expected | Go Implementation Note |
|----------|--------------|----------------------|
| Unknown keys caught and reported | Prevents silent typos (user writes `frequncy`, expects it to work) | `go-toml/v2` `DisallowUnknownFields()` or BurntSushi's `Undecoded()` check |
| Line number in error message | Users need to know where the problem is | Both go-toml/v2 DecodeError and BurntSushi include position info |
| Human-readable field path | "invalid value for `rules[0].waveform`" not "decode error" | go-toml v2's `DecodeError` produces contextualized messages |
| Invalid enum values rejected | `waveform = "sqaure"` (typo) should list valid options | Post-decode validation loop with explicit error message listing valid values |
| Out-of-range numbers rejected | `frequency = -50` or `frequency = 25000` should fail with reason | Post-decode bounds check with message |
| Missing required fields in new rules | A user rule block missing `protocol` is ambiguous | Post-decode presence check |
| Config error prevents startup | Do not silently ignore errors and run with partial config | Error should exit with non-zero and print the problem before capturing any packets |
**Critical:** Validation errors must surface before capture begins. A user who runs the tool, captures for 30 minutes, then gets a corrupt MP3 because a config value was silently ignored would rightly be frustrated.
### Error Reporting: Standard UX Patterns
From studying tools in the same class (golangci-lint, hugo, suricata):
- Print config errors to **stderr** (not stdout).
- Prefix with the config file path: `netsynth.toml:12: unknown field "frequncy"`.
- List ALL errors found in one pass rather than stopping at the first error. Users prefer fixing 5 things in one edit over 5 sequential runs.
- Warn (not error) for non-fatal issues such as "config file found but empty" or "unknown field in a comment-like position" — but for NetSynth's scope, unknown keys should be hard errors to prevent silent misconfigurations.
- On `--config path` flag with missing file: hard error immediately.
- On auto-discovered config with missing file: silent success (no config = defaults).
---
## Table Stakes for v1.1
Features users expect in any CLI tool that introduces a config file. Missing these makes v1.1 feel incomplete.
| Feature | Why Expected | Complexity | Depends On |
|---------|--------------|------------|------------|
| TOML config file auto-discovery (`./netsynth.toml`, `~/.config/netsynth/config.toml`) | Standard CLI convention; users expect zero-flag discovery | LOW | New: config loader module |
| `--config` flag for explicit path | Required when multiple configs exist or working dir is wrong | LOW | New: config loader + cobra flag |
| Partial override semantics (absent keys retain defaults) | Users must not copy the entire default table to change one field | LOW | New: merge logic |
| Custom frequency per known traffic class | Core v1.1 ask; directly maps to `synth.FreqConfig.BaseHz` | LOW | Existing `synth.ClassFreqConfigs` |
| Custom waveform per known traffic class | Core v1.1 ask; maps to `synth.Oscillator.Advance()` harmonic shape | MEDIUM | Existing oscillator (needs waveform type support) |
| User-defined classification rules with custom sounds | Core v1.1 ask; prepend to `classify.DefaultRules` | MEDIUM | Existing `classify.Rule` struct (needs `Class` name generation) |
| Config validation with line-number errors | Users cannot fix config errors without location info | LOW | go-toml v2 DecodeError (built-in) |
| Unknown field detection | Prevents silent typos | LOW | go-toml v2 `DisallowUnknownFields()` |
| Startup-time validation (fail before capture) | No wasted captures with bad config | LOW | Load config in `cmd` root before starting capture |
| Clear error message listing valid enum values | `waveform` has exactly 4 valid values; list them on error | LOW | Post-decode validation |
## Differentiators for v1.1
Features that make the config experience polished beyond the minimum.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Auto-clustering of unrecognized traffic | Unknown traffic still gets a unique voice instead of being silently dropped — honest audio fingerprint | HIGH | Requires unsupervised clustering (e.g. flow-feature vector → k-means or simple hash bucketing); each cluster gets a deterministic frequency mapping |
| Ambient/drone style output (layered sine harmonics) | Distinct from event-ping tools (Peep, SoNSTAR); slow tonal evolution makes long captures listenable | HIGH | Synthesize per-protocol drone layers; amplitude driven by time-windowed packet rate; mix layers before encoding |
| Time-windowed amplitude evolution | Traffic volume changes over time are reflected in the audio; the mix evolves rather than being static | MEDIUM | Segment capture into N-second windows; compute per-layer gain per window; apply smooth gain ramps between windows |
| Configurable time window duration (`--window 10`) | Lets users tune responsiveness vs. smoothness; research tools (SoNSTAR) expose this parameter | LOW | Default 10s; range 160s is sensible |
| BPF capture filter support (`--filter "tcp port 443"`) | Power users want to scope what gets sonified; tcpdump BPF syntax is universally known | MEDIUM | Pass expression directly to `gopacket`/`pcap`; validate at startup before capture begins |
| Offline pcap file input (`--read capture.pcap`) | Lets users sonify historical captures, not just live traffic; useful for analysis and demos | MEDIUM | Replace live capture source with `pcap.OpenOffline()`; time-compress or time-expand to fixed output duration |
| Verbose protocol activity log to stderr (`--verbose`) | Developers and curious users want to see what was classified | LOW | Print per-window protocol breakdown table to stderr during capture |
| Configurable output duration when reading pcap file (`--duration 30`) | Offline pcap may span hours; need ability to compress to a target audio length | LOW | Only meaningful with `--read`; scale time windows proportionally |
| Single static binary (`go build`) | Eliminates dependency hell on target machines | LOW (build-time) | Go native; no CGo for MP3 encoding avoids runtime `.so` requirements — choose a pure-Go MP3 encoder |
| `netsynth --print-config` command to dump effective config as TOML | Users want to see what defaults they're overriding; essential for creating a starting-point config file | LOW | Marshal `ClassFreqConfigs` + active rules to TOML; makes discoverability easy |
| Config documentation via inline comments in generated TOML | When `--print-config` outputs commented TOML, users get self-documenting starting point | LOW | Write comment strings alongside marshaled output |
| Named custom rules (user assigns a label) | User writes `name = "MyApp"` in a rule block; that name appears in the exit summary and `--verbose` output | LOW | Extend `classify.Rule` to carry optional display name |
| Waveform preview hint in config error message | "valid waveforms: sine, square, sawtooth, triangle" inline with the error | LOW | Hard-code the valid set in the validator |
| Harmonic override per class (not just base frequency) | Advanced users can tune the timbre, not just the pitch | MEDIUM | Requires exposing `HarmonicDef` slice in TOML schema; nesting adds parsing complexity |
### Anti-Features (Commonly Requested, Often Problematic)
## Anti-Features for v1.1
Features that seem good but create problems.
Features that seem natural but should be avoided.
| Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------|
| Real-time audio playback (speakers while capturing) | Feels more immediate; Peep and dmeldrum6/Network-Sonification do this | Requires platform audio APIs (ALSA/CoreAudio/WASAPI), cross-platform complexity triples; conflicts with file-output simplicity; latency/buffering bugs; CGo or external library dependency | File output only; user can pipe MP3 to `mpv`/`afplay` themselves after capture |
| GUI or web dashboard | Visually richer; existing tools like Network-Sonification are GUI-first | Negates single-binary CLI value; doubles scope; Go GUI toolkits are immature or require CGo | Emit stderr text summary; let external tools consume the MP3 |
| Custom sound mapping configuration file | Power-user request; SoNSTAR supports per-user sound uploads | Configuration surface area is large; predefined + auto-cluster covers the use case adequately for v1; config files introduce parsing/validation work | Well-chosen defaults + auto-cluster for unknowns; defer custom mapping to v2 |
| Rhythmic/percussive output mode | Some sonification tools use discrete note triggers per packet | Ambient/drone style is the deliberate differentiator; per-packet triggers at high traffic volumes produce noise, not information | Stick to amplitude-modulated harmonic drones; volume changes carry the rhythm implicitly |
| Deep-packet inspection / payload parsing | Users might want to hear HTTP body content, TLS handshake details | Requires reassembly, encryption handling, legal concerns about payload interception; massive complexity | Classify by header fields only (port, protocol, flags, packet size); that is sufficient for the audio fingerprint goal |
| Streaming MP3 output (write while capturing) | Real-time preview of what's being synthesized | MP3 frame boundaries and VBR headers require the full file to be finalized; streaming output would produce a non-standard file | Write to temp buffer during capture, finalize and flush on Ctrl+C |
| Anomaly detection / alerting | Natural extension once you have classified traffic | Adds a monitoring-tool responsibility on top of the audio-fingerprint responsibility; these are different user jobs | Stick to "produce an audio fingerprint"; anomaly detection is a separate tool |
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Config file hot-reload during capture | Appears useful but mid-capture parameter change would corrupt synthesis state and produce jarring audio discontinuities | Require restart to apply config changes; document this explicitly |
| Environment variable config overrides | Adds a third precedence layer (flags > env > file > defaults) that increases combinatorial test surface with low user demand for this tool | Stick to flags + file + defaults; NetSynth is not a server needing 12-factor config |
| Multiple config file includes / inheritance (`extends = "base.toml"`) | Sounds powerful, creates debugging nightmares when users do not understand the merge order | Single user config file merged with in-code defaults is sufficient; if a user needs multiple environments they can use `--config` |
| YAML or JSON config format as alternatives | "Why not YAML?" is a common request; supporting multiple formats multiplies parser dependency surface and doubles validation code paths | TOML only; document the choice (TOML is unambiguous, has clean table syntax, is the standard for Go tooling) |
| Silent partial load on validation error | Some tools load what they can and warn about the rest | Hard error on any invalid field; the user's intent for that field is unknown, so continuing is worse than stopping |
| Config wizard / interactive setup | Out of scope for a CLI tool with a non-interactive model | Provide `--print-config` with comments as a self-service starting point |
| Stereo pan position in config | Requested but explicitly deferred in PROJECT.md for this milestone | Out of scope for v1.1; document as v1.2 candidate |
---
## Feature Dependencies
## Feature Dependencies for v1.1
```
[Interface selection / list-interfaces]
└──required-by──> [Live capture]
[Config file loader (TOML parse + merge)]
|
+--provides--> [Custom frequency overrides] (maps to synth.FreqConfig.BaseHz)
|
+--provides--> [Custom waveform per class] (requires oscillator waveform dispatch)
| |
| +--requires--> [Waveform type in oscillator] (new: sine/square/sawtooth/triangle)
|
+--provides--> [User-defined classification rules]
|
+--requires--> [Dynamic TrafficClass generation] (new: user rule class names)
+--prepended-to--> [classify.DefaultRules]
[Live capture] ──OR── [Offline pcap input]
└──required-by──> [Protocol classification]
└──required-by──> [Auto-clustering of unknowns]
└──required-by──> [Per-protocol drone layer synthesis]
└──required-by──> [Time-windowed amplitude evolution]
└──required-by──> [Layer mixing]
└──required-by──> [MP3 encoding & file output]
[BPF capture filter] ──enhances──> [Live capture]
[Configurable time window] ──tunes──> [Time-windowed amplitude evolution]
[Offline pcap + --duration] ──requires──> [Offline pcap input]
[Verbose flag] ──enhances──> [Protocol classification] (reporting only, no data dependency)
[Graceful Ctrl+C] ──requires──> [MP3 encoding & file output] (must flush before exit)
[--config flag] --overrides--> [Config file loader search path]
[--print-config] --reads--> [Effective config after merge] (new subcommand)
```
### Dependency Notes
### Dependency Notes for v1.1
- **Protocol classification requires Live capture OR Offline pcap:** These are the two data sources; everything downstream is source-agnostic.
- **Time-windowed amplitude evolution requires Protocol classification:** You need classified packet counts per window before you can derive per-layer gain values.
- **MP3 encoding requires Layer mixing:** You cannot encode until all layers for a time window are mixed to a PCM buffer.
- **Graceful Ctrl+C requires MP3 encoding:** The signal handler must trigger the encode-and-flush path, not just `os.Exit`.
- **Auto-clustering enhances Protocol classification:** It extends classification to traffic that doesn't match predefined rules; the audio pipeline treats cluster-assigned tones identically to predefined protocol tones.
- **BPF filter conflicts with Offline pcap input (partial):** `pcap` supports BPF on offline files, so this works technically, but user expectation for offline mode is usually "sonify all traffic in the file" — document the interaction clearly.
- **Waveform type is a new concept in the oscillator.** The v1.0 `Oscillator.Advance()` only does additive sine. To support square/sawtooth/triangle, the oscillator needs a `WaveformType` field and dispatch logic. This is an internal change, but it's required before waveform config can be wired up.
- **User-defined rules require dynamic `TrafficClass` values.** v1.0 `TrafficClass` is a string type with predefined constants. User rules name their own classes (e.g., `"MyApp"`). The classifier already uses `TrafficClass` as a string; the `synth` layer needs to handle classes not in `ClassFreqConfigs` by looking up user-supplied sound parameters.
- **Config loading must happen in `cmd` before the capture pipeline starts.** The cobra root command's `RunE` (or `PersistentPreRunE`) function loads and validates config, then passes effective config into the pipeline constructors. This is a structural change to `cmd/root.go`.
- **`--print-config` is independent** of capture and can be implemented as a separate cobra subcommand reading only the config loader output.
---
## MVP Definition
## Implementation Complexity Summary
### Launch With (v1)
| Feature | Complexity | Reason |
|---------|------------|--------|
| Config file loader (TOML parse + merge + validation) | LOW-MEDIUM | go-toml v2 handles parsing; merge logic is a loop; validation is a post-decode pass |
| Custom frequency per class | LOW | Direct map lookup override; one line per class |
| Custom waveform per class | MEDIUM | Oscillator needs waveform dispatch (new `WaveformType`); synthesis loop changes |
| User-defined classification rules | MEDIUM | Dynamic class names; synth layer must handle unknown class names via config lookup |
| `--config` flag + auto-discovery | LOW | Cobra flag + os.Stat checks on 2-3 paths |
| `--print-config` subcommand | LOW | Marshal effective config to TOML; add comments |
| Named custom rules in exit summary | LOW | `classify.Rule` struct gains optional `Name string` field |
Minimum viable product — what's needed to validate the concept.
- [ ] Network interface selection (`-i`) and `--list-interfaces` — required for capture
- [ ] Live packet capture with Ctrl+C stop — core interaction model
- [ ] Protocol classification: ICMP, DNS, TCP/443, TCP/other, UDP — minimum set for a recognizable fingerprint
- [ ] Auto-clustering of unrecognized traffic (simple hash-bucketing by port/proto is acceptable for v1) — honest representation of full traffic
- [ ] Per-protocol ambient drone layer synthesis (sine harmonics, amplitude modulated by packet rate) — core differentiator
- [ ] Time-windowed amplitude evolution (10s default) — makes the output dynamic
- [ ] MP3 encoding and file output with sensible default filename — deliverable artifact
- [ ] Capture statistics summary on exit (stderr) — basic UX courtesy
- [ ] Privilege error detection and clear message — prevents silent failure
### Add After Validation (v1.x)
Features to add once core is working.
- [ ] BPF capture filter (`--filter`) — add when users report wanting to scope captures
- [ ] Offline pcap file input (`--read`) — add when users want to sonify historical captures
- [ ] Verbose protocol activity log (`--verbose`) — add when debugging/demo use cases emerge
- [ ] Configurable time window duration (`--window`) — add if users report default feels too slow or too fast
- [ ] Configurable output duration for pcap input (`--duration`) — depends on offline input being implemented
### Future Consideration (v2+)
Features to defer until product-market fit is established.
- [ ] Custom sound mapping configuration — defer; needs user research on what customization actually matters
- [ ] Improved clustering algorithm (k-means on flow features vs. simple hash) — defer until users report clusters feel meaningless
- [ ] Multi-interface capture — defer; adds complexity to packet deduplication
**No new external dependencies required.** go-toml v2 is the only addition to `go.mod`.
---
## Feature Prioritization Matrix
## TOML Schema Sketch (Informational)
| Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------|
| Interface selection + list-interfaces | HIGH | LOW | P1 |
| Live capture with Ctrl+C stop | HIGH | LOW | P1 |
| Protocol classification (ICMP, DNS, TCP, UDP) | HIGH | MEDIUM | P1 |
| Ambient drone synthesis (per-protocol layers) | HIGH | HIGH | P1 |
| Time-windowed amplitude evolution | HIGH | MEDIUM | P1 |
| MP3 encoding + file output | HIGH | MEDIUM | P1 |
| Capture stats on exit | MEDIUM | LOW | P1 |
| Privilege error message | MEDIUM | LOW | P1 |
| Auto-clustering of unknown traffic | MEDIUM | MEDIUM | P1 |
| BPF capture filter | MEDIUM | MEDIUM | P2 |
| Offline pcap file input | MEDIUM | MEDIUM | P2 |
| Verbose flag | LOW | LOW | P2 |
| Configurable time window | LOW | LOW | P2 |
| Custom sound mapping | LOW | HIGH | P3 |
| Real-time playback | LOW | HIGH | P3 (anti-feature, avoid) |
This is not a binding decision — it informs the roadmap's implementation phase. The schema should feel natural to a user who has seen other Go tool configs (golangci-lint, goreleaser).
**Priority key:**
- P1: Must have for launch
- P2: Should have, add when possible
- P3: Nice to have, future consideration
```toml
# Override built-in protocol sounds
[classes.HTTPS]
frequency = 220.0
waveform = "square" # sine | square | sawtooth | triangle
[classes.DNS]
frequency = 90.0
# Add custom classification rules (prepended before built-in rules, first-match-wins)
[[rules]]
name = "Internal API"
protocol = "tcp"
port = 8443
frequency = 300.0
waveform = "sawtooth"
[[rules]]
name = "Game Traffic"
protocol = "udp"
port = 27015
frequency = 450.0
waveform = "triangle"
```
Key schema design choices:
- `[classes.X]` uses the same class name strings already used in `--verbose` output and exit summary (`HTTPS`, `DNS`, etc.) — no new naming system to learn.
- `[[rules]]` is a TOML array of tables, consistent with how goreleaser and other tools express lists of items.
- `protocol` and `port` map directly to the existing `classify.Rule` fields, minimizing translation.
- Waveform is an enum string, not an integer — readable and self-documenting in the config file.
---
## v1.0 Feature Landscape (Retained from Original Research)
### Table Stakes (v1.0)
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Network interface selection (`-i eth0`) | tcpdump/tshark convention | LOW | Implemented: v1.0 |
| Output file path flag (`-o output.mp3`) | Any file-producing CLI | LOW | Implemented: v1.0 |
| Graceful Ctrl+C with file save | Users expect clean finalize | MEDIUM | Implemented: v1.0 |
| Per-protocol sound distinction | Core value prop | MEDIUM | Implemented: v1.0, 12 rules |
| Packet count / traffic summary on exit | Every capture tool does this | LOW | Implemented: v1.0 |
| Privilege error message | Silent pcap failure is confusing | LOW | Implemented: v1.0 |
| List available interfaces (`--list-interfaces`) | Users don't know interface names | LOW | Implemented: v1.0 |
| Minimum viable duration guard | Zero-packet = no corrupt MP3 | LOW | Implemented: v1.0 |
### Differentiators (v1.0)
| Feature | Value Proposition | Complexity | Status |
|---------|-------------------|------------|--------|
| Auto-clustering of unrecognized traffic | Honest audio fingerprint | HIGH | Implemented: hash-bucket, 4 classes |
| Ambient/drone style (layered sine harmonics) | Distinct from event-ping tools | HIGH | Implemented: v1.0 |
| Time-windowed amplitude evolution | Mix evolves dynamically | MEDIUM | Implemented: 500ms windows + EMA |
| BPF capture filter (`--filter`) | Power users scope what's sonified | MEDIUM | Implemented: v1.0 |
| Offline pcap file input (`--read`) | Sonify historical captures | MEDIUM | Implemented: v1.0 |
| Verbose protocol activity log (`--verbose`) | Developers see classifications | LOW | Implemented: v1.0 |
### Anti-Features (v1.0)
| Feature | Why Avoided |
|---------|-------------|
| Real-time audio playback | Platform audio API complexity; file output is correct |
| GUI or web dashboard | Negates single-binary CLI value |
| Custom sound mapping (v1.0) | Deferred to v1.1 — now the current milestone |
| Rhythmic/percussive output | Ambient/drone is the deliberate differentiator |
| Deep-packet inspection | Massive complexity; header classification sufficient |
| Streaming MP3 output | MP3 finalization requires full buffer |
| Anomaly detection / alerting | Different user job |
---
## Competitor Feature Analysis
| Feature | SoNSTAR (Python, research) | Network-Sonification (C#, Windows GUI) | Peep (C, Unix, 2000) | NetSynth (our approach) |
|---------|----------------------------|-----------------------------------------|----------------------|------------------------|
| Interface selection | Interactive prompt | GUI dropdown | Config file | CLI flag `-i` |
| Protocol coverage | TCP flag states | TCP, UDP, HTTP, HTTPS, DNS, ICMP | Any syslog-able event | ICMP, DNS, TCP, UDP + auto-cluster |
| Sound style | Recorded natural sounds (forest ambience) | Waveform shapes per protocol (sine/square/triangle) | Discrete event sounds | Synthesized harmonic drones |
| Output | Real-time audio (Max/MSP) | Real-time audio (WPF) | Real-time audio (Unix audio) | MP3 file |
| Time aggregation | Configurable window (default 20s) | Per-packet event | Per-event | Configurable window (default 10s) |
| CLI/scriptable | Partial (Python prompts) | No (GUI only) | Yes (daemon) | Yes (single binary, flags) |
| Offline pcap input | No | No | No | v1.x |
| Auto-clustering | No | No | No | Yes (v1 hash-bucket) |
| Single binary | No | No | No | Yes (Go) |
| Open source | Yes | Yes | Yes | Intended |
| Feature | SoNSTAR (Python) | Network-Sonification (C# GUI) | Peep (C, Unix) | NetSynth v1.0 | NetSynth v1.1 |
|---------|-----------------|-------------------------------|----------------|----------------|----------------|
| Custom sound config | No | No | Config file (fixed format) | No | Yes (TOML) |
| Config file discovery | n/a | n/a | Hardcoded path | n/a | XDG + working dir |
| Partial override semantics | n/a | n/a | Full replacement | n/a | Partial override |
| Waveform selection | Recorded samples | sine/square/triangle | Fixed | sine only | sine/square/sawtooth/triangle |
| Custom classification rules | No | No | No | No | Yes (user-defined port/proto rules) |
| Named custom classes | n/a | n/a | n/a | n/a | Yes (appears in summary output) |
---
## Sources
- [SoNSTAR: Sonification of Networks for SiTuational AwaReness — Paul Vickers](https://paulvickers.github.io/SoNSTAR/)
- [Sonification of network traffic flow for monitoring and situational awareness — PLOS One](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0195948)
- [SoNSTAR GitHub repository — nuson/SoNSTAR](https://github.com/nuson/SoNSTAR)
- [Network-Sonification GitHub — dmeldrum6/Network-Sonification](https://github.com/dmeldrum6/Network-Sonification)
- [Peep (The Network Auralizer): Monitoring Your Network With Sound — USENIX 2000](https://www.usenix.org/legacyurl/peep-network-auralizer-monitoring-your-network-sound)
- [Sonification of DDoS Attacks — Imperva](https://www.imperva.com/blog/archive/sonification-of-ddos-attacks/)
- [Data Sonification Toolkit — Sound and data parameters](https://www.sonificationkit.com/data-sonification/concepts/sound-and-data-parameters)
- [tcpdump man page — tcpdump.org](https://www.tcpdump.org/manpages/tcpdump.1.html)
- [The Sound of Data: A gentle introduction to sonification — Programming Historian](https://programminghistorian.org/en/lessons/sonification)
- [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/) — standard for `~/.config` discovery path
- [adrg/xdg — Go XDG implementation](https://github.com/adrg/xdg) — if explicit XDG library is needed (probably not for NetSynth's 2-path lookup)
- [pelletier/go-toml v2 — strict mode and DecodeError](https://pkg.go.dev/github.com/pelletier/go-toml/v2) — recommended TOML library; DisallowUnknownFields() and human-readable errors
- [BurntSushi/toml — Undecoded() for unknown key detection](https://github.com/BurntSushi/toml) — alternative; simpler API but less actively maintained
- [Building CLI Applications with Go: Cobra and Viper Guide (2026)](https://dasroot.net/posts/2026/03/building-cli-applications-go-cobra-viper/) — config loading patterns in Cobra CLI tools
- [A Guide to TOML in Golang — kelche.co](https://www.kelche.co/blog/go/toml/) — go-toml v2 vs BurntSushi comparison and practical examples
- [Configuration | mise-en-place](https://mise.jdx.dev/configuration.html) — example of working-dir + XDG config discovery
- [golangci-lint configuration](https://golangci-lint.run/docs/configuration/cli/) — real-world example of partial override config in a Go CLI tool
- [Online Tone Generator — waveform types](https://onlinetonegenerator.com/) — confirms sine/square/sawtooth/triangle as the standard 4 waveform set
---
*Feature research for: network traffic sonification CLI (NetSynth)*
*Researched: 2026-03-24*
*v1.0 research: 2026-03-24*
*v1.1 custom sound mappings research: 2026-03-26*
+391 -213
View File
@@ -1,333 +1,511 @@
# Pitfalls Research
**Domain:** Network-traffic-to-audio synthesis CLI tool (Go)
**Researched:** 2026-03-24
**Confidence:** HIGH (packet capture / CGo pitfalls verified against official issues and docs; audio synthesis pitfalls cross-referenced against encoder project post-mortems and DSP literature)
**Researched:** 2026-03-26 (v1.1 update — TOML config, waveform types, user-defined rules)
**Confidence:** HIGH (TOML decoder behaviors verified against pkg.go.dev official docs and issue trackers; audio synthesis aliasing verified against DSP literature; config merging verified against BurntSushi/toml issue #47 and go-toml issue #252)
---
## Critical Pitfalls
## v1.1 Milestone Pitfalls (New)
### Pitfall 1: Using `google/gopacket` Instead of the Active Community Fork
These pitfalls are specific to adding TOML config, waveform types, and user-defined classification rules to the existing NetSynth codebase.
---
### Pitfall A1: TOML Unmarshal Silently Overwrites Pre-filled Defaults with Zero Values
**What goes wrong:**
The original `github.com/google/gopacket` repository is unmaintained. Bugs go unpatched, open PRs accumulate, and compatibility with newer Go versions degrades. Projects that import it are pinned to a stale library.
You initialize a `Config` struct with built-in defaults, then call `toml.Unmarshal` to layer in user overrides. Any field the user *omits* from their TOML file is set to its Go zero value (`0`, `""`, `false`, `nil`) by the decoder — overwriting your defaults. A user who writes only `[sounds.DNS]` in their config file to change the DNS tone ends up wiping every other class back to zero Hz.
**Why it happens:**
`google/gopacket` has enormous search mindshare and most tutorials still reference it. Developers reach for the first result without checking maintenance status.
Both `BurntSushi/toml` and `pelletier/go-toml` v1 do not distinguish between "key was absent" and "key was explicitly set to zero". The decoder reflects over the struct and writes zero for every absent key. This was explicitly reported as a bug in BurntSushi/toml issue #47 and go-toml issue #252. go-toml v2 partially addresses it but still zeros primitive-type fields that are absent.
**How to avoid:**
Import `github.com/gopacket/gopacket` (the community fork, v1.5.0 released November 2025, requires Go 1.24+). Major projects including Cilium have already migrated. Treat `google/gopacket` as deprecated.
**Consequences:**
- All non-overridden traffic classes play silence (0 Hz oscillator)
- Classification rules get zeroed if user only partially fills `[[rules]]`
- EMA tau, whisper floor, gain, and other synth parameters reset to 0
**Warning signs:**
- `go.mod` referencing `github.com/google/gopacket`
- Build errors on Go 1.21+ not fixed upstream
**Prevention:**
Use pointer fields (`*float64`, `*string`) in the decoded struct to distinguish "not provided" (nil pointer) from "explicitly set to zero" (non-nil pointer to 0). Apply a merge step: iterate over the decoded struct, and for each pointer field that is nil, keep the built-in default. For slice fields (like `[]RuleConfig`), nil slice means "user did not provide rules" — preserve defaults; non-nil empty slice (`[]RuleConfig{}`) means "user explicitly cleared rules" — respect that.
```go
// In config struct, use pointers for optional overrides:
type SoundConfig struct {
FreqHz *float64 `toml:"freq_hz"`
Waveform *string `toml:"waveform"`
}
// Merge: for each class, override only non-nil fields
func mergeSound(base synth.FreqConfig, override SoundConfig) synth.FreqConfig {
if override.FreqHz != nil {
base.BaseHz = *override.FreqHz
}
if override.Waveform != nil {
base.WaveformType = *override.Waveform
}
return base
}
```
**Detection:**
- User reports that classes they did not configure now produce no sound
- Unit test: load a config that overrides only one class; verify all other classes retain built-in Hz values
**Phase to address:**
Phase 1 (packet capture scaffolding) — set the correct import path from day one; migrating later is a find-and-replace across the whole codebase.
Config loading phase (first phase of v1.1). Get the pointer-and-merge pattern established before wiring config into the bank. Retrofitting after the bank construction is wired is a significant churn.
---
### Pitfall 2: CGo Destroys the "Single Binary" Promise
### Pitfall A2: BurntSushi/toml Silently Ignores Typos in Field Names
**What goes wrong:**
`gopacket/pcap` requires `libpcap` via CGo. By default Go produces a dynamically linked binary. On a target machine without `libpcap.so` installed, the binary silently or loudly fails with `error while loading shared libraries: libpcap.so.0.8`. The "just copy the binary" distribution story breaks completely.
A user writes `freq_hz = 440` but the struct tag is `toml:"freq_hz"` — this works. However if the user writes `freqhz = 440` or `FreqHz = 440` or a misspelled `frek_hz = 440`, the library silently ignores the key. The user's override is never applied. No error is returned. The user thinks their config is active; it is not.
**Why it happens:**
CGo is enabled by default and Go gives no compile-time warning that the resulting binary has a runtime C dependency. The binary runs perfectly on the build machine (which has libpcap-dev installed) and fails on clean machines.
`BurntSushi/toml` by default silently discards keys that do not map to any struct field. This is the documented default behavior ("will ignore options in the TOML file that you don't use"). It is the opposite of "strict mode."
**How to avoid:**
Choose one of these strategies before writing a line of capture code:
1. **Fully static build**: `CGO_ENABLED=1 go build -ldflags "-linkmode 'external' -extldflags '-static'"` with `libpcap.a` present. Requires `musl-gcc` or equivalent on Alpine/musl.
2. **pcapgo (pure Go)**: `gopacket/pcapgo` provides an `EthernetHandle` that avoids CGo entirely — lower performance but zero C dependency. Sufficient for ambient audio capture at non-Gbps rates.
3. **Document the dependency explicitly**: If CGo/dynamic linking is accepted, `README` must state "requires `libpcap` (`apt install libpcap-dev` / `brew install libpcap`)".
**Consequences:**
- Silent misconfiguration: user's customization is invisible
- Debugging is very hard — no error to trace back to the TOML file
**Warning signs:**
- `CGO_ENABLED` not explicitly set in your build script
- `ldd ./netsynth` shows `libpcap.so` as a dependency
- No CI test on a minimal (Alpine, scratch Docker) container
**Prevention:**
Use `toml.Decode` (not `toml.Unmarshal`) to obtain `MetaData`, then call `md.Undecoded()` and return an error listing any keys that were not decoded. This is BurntSushi's documented strict-mode pattern.
```go
md, err := toml.Decode(string(data), &cfg)
if err != nil {
return err
}
if keys := md.Undecoded(); len(keys) > 0 {
return fmt.Errorf("unknown config keys (check for typos): %v", keys)
}
```
**Detection:**
- Config change that should audibly alter the sound has no effect
- Undecoded keys present but no warning/error logged
**Phase to address:**
Phase 1 — this is a foundational architecture decision. Changing from dynamic to static after the fact is painful and causes build pipeline rewrites.
Config loading phase. Implement strict decoding from the first config load function. Do not add this as an afterthought — it is the primary mechanism protecting users from silent misconfiguration.
---
### Pitfall 3: `CAP_NET_RAW` + Binary Location = Silent Failure on Linux
### Pitfall A3: Naive Square/Sawtooth/Triangle Generation Produces Audible Aliasing Distortion
**What goes wrong:**
On Ubuntu and many Linux distributions, `setcap cap_net_raw+eip ./netsynth` appears to succeed but the binary fails at runtime if it lives in `/home/user/bin`, `/tmp`, or any filesystem mounted `nosuid`. The kernel silently ignores the capability. AppArmor compounds this by enforcing path-based restrictions.
Implementing waveforms by direct time-domain math — `sign(sin(phase))` for square, `2*frac(phase)-1` for sawtooth, `1-2*abs(frac(phase)-0.5)` for triangle — produces a waveform with infinite harmonics. At 44100 Hz, harmonics above 22050 Hz fold back into the audible range as aliasing. At the frequencies used in NetSynth (651047 Hz), aliasing from a naive square wave produces a buzzing distortion that is especially audible at higher drone frequencies and sounds like corruption rather than timbre.
**Why it happens:**
Developers test from their build directory (`~/projects/netsynth/`) — a path frequently on a `nosuid` filesystem. The tool appears broken with no clear error message beyond "permission denied" or "you must be root."
The mathematical waveforms are not bandlimited — they have infinite harmonic content. Direct sampling them at 44100 Hz aliases all energy above Nyquist back into the audible band. Developers who test at low frequencies (60120 Hz) may not notice because the aliased harmonics land at very high frequencies with low perceptual impact; the problem worsens significantly above 400 Hz where aliases fold into the 15 kHz perceptually prominent range.
**How to avoid:**
- Install to `/usr/local/bin` or `/usr/bin` for capability-based operation
- Document two run modes: `sudo ./netsynth` (always works) vs. `setcap` (requires standard path)
- In the CLI, detect permission failure and emit a clear message: "Packet capture requires root or CAP_NET_RAW. Run as root or: sudo setcap cap_net_raw+eip $(which netsynth)"
- Test capability-mode explicitly from a non-home path in CI
**Consequences:**
- Square/sawtooth at SSH (330 Hz) and higher frequencies sounds harsh and buzzy
- The effect worsens at higher frequencies, making SMTP (440 Hz) and DHCP (600 Hz) drones sound distorted
- Aliasing cannot be filtered out post-synthesis (it is interleaved with desired signal)
**Warning signs:**
- Testing only via `sudo go run .`
- No test of the installed-binary path in README instructions
- macOS-only development (macOS uses a different privilege model; Linux `nosuid` behavior won't surface)
**Prevention:**
Use additive synthesis — the approach already in use for sine waves in `oscillator.go`. The existing `Oscillator.Advance(harmonics []HarmonicDef)` computes `sin(2π * phase * ratio)` for each partial. Square, sawtooth, and triangle waveforms are all expressible as harmonic series:
- **Square:** odd harmonics only, amplitude `1/k` for harmonic `k`: ratios 1, 3, 5, 7, ... with amplitudes 1.0, 0.33, 0.20, 0.14, ... Truncate at Nyquist.
- **Sawtooth:** all harmonics, amplitude `1/k`: ratios 1, 2, 3, 4, ... with amplitudes 1.0, 0.5, 0.33, 0.25, ... Truncate at Nyquist.
- **Triangle:** odd harmonics, amplitude `1/k²`, alternating sign: ratios 1, 3, 5, ... with amplitudes 1.0, 0.11, 0.04, ... Truncate at Nyquist.
The truncation (only sum harmonics where `freq * ratio < sampleRate / 2`) is the critical step that makes the synthesis bandlimited. The existing `[]HarmonicDef` structure in `synth/config.go` already supports this — waveform type selection just requires generating the right harmonic series for each `FreqConfig`.
Waveform presets should be pre-computed `[]HarmonicDef` slices, not runtime computation of naive waveform math:
```go
// BandlimitedHarmonics returns a bandlimited harmonic series for the given waveform type.
// It truncates harmonics at Nyquist (sampleRate/2) to prevent aliasing.
func BandlimitedHarmonics(waveform string, baseHz float64, sampleRate int) []HarmonicDef {
nyquist := float64(sampleRate) / 2.0
var defs []HarmonicDef
switch waveform {
case "square":
for k := 1; float64(k)*baseHz < nyquist; k += 2 { // odd only
defs = append(defs, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
}
case "sawtooth":
for k := 1; float64(k)*baseHz < nyquist; k++ {
defs = append(defs, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
}
case "triangle":
sign := 1.0
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
defs = append(defs, HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)})
sign = -sign
}
default: // "sine"
defs = []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
}
return defs
}
```
**Detection:**
- Audible buzzing or grainy texture on drone layers above 300 Hz with non-sine waveforms
- Square/sawtooth waveforms sound harsher than expected at high frequencies
**Phase to address:**
Phase 1 (capture scaffolding) and the CLI UX phase — the error message is user-facing and needs to be explicit.
Waveform type implementation phase. The design decision (additive synthesis, not direct waveform math) must be made before coding waveform support. Switching from direct math to additive after the fact requires rewriting the oscillator API.
---
### Pitfall 4: Packet Buffer Overflow Under Moderate Traffic Load
### Pitfall A4: Waveform String Validation Fails Silently, Falls Back to Silence
**What goes wrong:**
At high packet rates (busy LAN, server NIC), gopacket's kernel ring buffer fills faster than the processing goroutine consumes it. The OS drops packets silently. The tool appears to work, but 50-98% of packets never reach the classifier. The audio output misrepresents actual traffic.
A user writes `waveform = "Sawtooth"` (capital S) or `waveform = "saw"` (abbreviation). The config loading code does a simple equality check (`if waveform == "sawtooth"`), finds no match, and either panics, silently emits silence, or applies a default without telling the user. In all cases the user's intent is invisible.
**Why it happens:**
The default pcap buffer is 1-2 MB. Each packet triggers a cgo call (with `pcap` backend), creating per-packet overhead that compounds at speed. Developers test on quiet home networks and never observe drops.
String-based enumerations in config files have no compile-time type checking. Case sensitivity and abbreviations are user expectations that must be explicitly handled.
**How to avoid:**
- Set a large capture buffer explicitly: `handle.SetBufferSize(32 * 1024 * 1024)` (32 MB)
- Use a non-blocking channel between capture and classification goroutines with a buffer of at least 1000 packets; drop metrics count drops so they are visible
- For high-throughput scenarios, prefer `afpacket` backend over `pcap``afpacket` eliminates per-packet CGo calls and dramatically improves throughput (benchmark: 1.27 MB/s → 21.17 MB/s)
- NetSynth's ambient audio goal tolerates lossy capture — document this explicitly so users understand the tool provides a statistical fingerprint, not a perfect census
**Consequences:**
- Silent misconfiguration: wrong waveform with no feedback
- Hard to debug: config appears valid, sound is just wrong
**Warning signs:**
- Capture and classification in a single goroutine
- No `SetBufferSize` call
- Testing only on loopback (`lo`) which has near-zero real packet rates
**Prevention:**
Normalize waveform strings at parse time (`strings.ToLower`, `strings.TrimSpace`), validate against the accepted set, and return an explicit error with the accepted values if the string is unrecognized:
```go
var validWaveforms = map[string]struct{}{
"sine": {}, "square": {}, "sawtooth": {}, "triangle": {},
}
func validateWaveform(s string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(s))
if _, ok := validWaveforms[normalized]; !ok {
return "", fmt.Errorf("unknown waveform %q: must be one of sine, square, sawtooth, triangle", s)
}
return normalized, nil
}
```
**Phase to address:**
Phase 1/2 (capture pipeline) — the goroutine architecture must be designed for async processing from the start. Retrofitting is a significant rewrite.
Config validation step (same phase as config loading). Implement all string field validation in a single `validate(cfg Config) error` function called immediately after decoding.
---
### Pitfall 5: ZeroCopy Packet Data Use-After-Free
### Pitfall A5: User Rules Appended After Catch-All Rules Are Unreachable
**What goes wrong:**
`ZeroCopyReadPacketData()` returns a slice pointing into a buffer owned by the pcap handle. The next call to `ZeroCopyReadPacketData()` invalidates the previous slice's backing memory. If any goroutine holds a reference to old packet bytes and reads them after the next call, it reads corrupted or incorrect data. This produces silent data corruption — wrong protocol classifications, no crash.
The existing `DefaultRules` slice ends with two catch-alls:
```go
{Protocol: "tcp", DstPort: 0, Class: ClassOtherTCP},
{Protocol: "udp", DstPort: 0, Class: ClassOtherUDP},
```
If user-defined rules are simply appended to this slice (`append(DefaultRules, userRules...)`), the catch-alls match first (DstPort=0 matches any port for that protocol), and the user's rules are unreachable. Every custom rule maps to `ClassOtherTCP` or `ClassOtherUDP` instead. The user gets no sound from their custom class.
**Why it happens:**
The zero-copy API looks identical to the copying API. Developers reach for it for performance without reading the "each call invalidates previous data" contract.
The first-match-wins semantics of `Classifier.Classify()` mean ordering is semantically critical. `DefaultRules` is a named var that exists precisely as an ordered slice — the comment `// Catch-alls (must be last)` documents this constraint. But "must be last in the defaults" does not automatically mean "must be last in the final merged slice." Developers who concatenate slices without thinking about this invariant break the system.
**How to avoid:**
Use `ReadPacketData()` (copies data) unless you have profiling evidence that allocation is a bottleneck. If `ZeroCopyReadPacketData()` is used, never pass the slice to another goroutine without first copying it: `data := append([]byte(nil), raw...)`.
**Consequences:**
- All user-defined rules are silently swallowed by catch-alls
- User's custom class never activates
- No error — the pipeline works, just wrong
**Warning signs:**
- `ZeroCopyReadPacketData` in a goroutine-per-packet pattern
- Intermittent wrong protocol classifications that are not reproducible
- Using `gopacket.Lazy` decode with concurrent goroutines (the gopacket docs explicitly warn against this combination)
**Prevention:**
Always insert user rules *before* catch-all rules. The merge strategy must be: `specificDefaultRules + userRules + catchAllRules`. Implement this with an explicit split in the default rule set:
```go
// In classify/rules.go, split into two exported slices:
var SpecificRules = []Rule{ /* ICMP through DHCP */ }
var CatchAllRules = []Rule{
{Protocol: "tcp", DstPort: 0, Class: ClassOtherTCP},
{Protocol: "udp", DstPort: 0, Class: ClassOtherUDP},
}
// Merge function used by config loading:
func MergeRules(userRules []Rule) []Rule {
result := make([]Rule, 0, len(SpecificRules)+len(userRules)+len(CatchAllRules))
result = append(result, SpecificRules...)
result = append(result, userRules...)
result = append(result, CatchAllRules...)
return result
}
```
Alternatively, annotate each default rule with a `CatchAll bool` field and sort before use. The split-slice approach is simpler and more explicit.
**Detection:**
- User-defined rule that should match traffic does not produce its custom sound
- `--verbose` output shows traffic being classified as `OtherTCP`/`OtherUDP` instead of the custom class
- Test: write a rule for port 8080, send HTTP traffic to port 8080, verify it hits the custom class and not `ClassOtherTCP`
**Phase to address:**
Phase 1 (capture/decode) — establish the correct API choice at the read loop level.
User-defined rules phase. The `classify/rules.go` split must be the first code change before any config loading logic references the rule slice.
---
### Pitfall 6: MP3 Output Is Corrupt or Unplayable Due to LAME Initialization Errors
### Pitfall A6: User-Defined Classes Have No synth.FreqConfig Entry — Bank Panics or Plays Silence
**What goes wrong:**
MP3 encoding via CGo LAME bindings requires calling `InitParams()` after setting all encoder parameters. Skipping or reordering this call produces a file with a valid `.mp3` extension that most players refuse to open or that plays as noise. The encoder returns no error from the encode calls themselves.
`OscillatorBank.NewBank()` iterates over `classify.AllClasses()` and looks up each class in `ClassFreqConfigs`. A user-defined rule creates a new `TrafficClass` (e.g., `"my-api"`). This class is not in `AllClasses()`, so the bank has no layer for it. The aggregator increments a count for `"my-api"`, `RenderWindow` looks up `b.layers["my-api"]`, gets nil, and either panics (nil pointer dereference on `layer.AdvanceSample()`) or silently contributes nothing to the mix.
**Why it happens:**
The LAME C API is stateful and order-dependent. Go wrappers vary in how much they enforce initialization order. Many tutorial examples show minimal code that happens to work for 44100 Hz stereo but silently breaks for other configurations.
`classify.AllClasses()` is a hardcoded list of the 14 built-in classes. The synth bank is constructed once at startup from this static list. User-defined classes are a runtime extension that the bank knows nothing about.
**How to avoid:**
- Always call `InitParams()` before writing any frames
- Restrict to known-safe parameters: sample rate 44100 or 48000, stereo or mono (LAME does not support dual-channel mode)
- Write a single integration test that encodes 1 second of silence and confirms the output file is valid (use `mp3val` or `ffprobe` in CI)
- Consider `shine-mp3` (pure Go port) as an alternative that eliminates CGo entirely; output files are larger but the library has no C initialization state
**Consequences:**
- Nil pointer panic in `RenderWindow` if the layer map lookup is not nil-guarded
- Or silent: user-defined class traffic is captured and aggregated but never rendered to audio
- In either case the user's primary feature request (custom sounds for custom classes) silently fails
**Warning signs:**
- No test that validates the output MP3 with an external tool
- Sample rate set to anything other than 44100 or 48000
- Encoder parameters set after `InitParams()` has been called
**Prevention:**
The bank must be constructed from the *full* set of active classes, including user-defined ones. The construction path should be:
1. Load config (parse TOML, validate)
2. Compute effective rule set (built-in + user rules)
3. Extract the complete set of `TrafficClass` values referenced by all rules
4. Pass this full class set to `NewBank` (or equivalent) so a layer is created for every reachable class
5. Wire user-defined class frequencies from config into the bank
`AllClasses()` in `classify/types.go` should either remain the static built-in list (used for display/iteration of built-ins) or be replaced by a dynamic function that takes the active rule set as input. Do not rely on the hardcoded list in the bank-construction path when user-defined classes are possible.
**Detection:**
- Panic: `runtime error: invalid memory address or nil pointer dereference` in `synth/bank.go:RenderWindow`
- Or: user-defined class produces no sound, no error
- Test: create a config with one user rule using a custom class; verify the bank is built with a layer for that class and that layer produces sound
**Phase to address:**
Audio synthesis / encoding phase — establish the encode pipeline with an end-to-end smoke test (silence → valid MP3) before wiring up synthesis.
User-defined rules phase, specifically the bank initialization step. This is the deepest integration point — it touches the pipeline at capture → classify → aggregate → synthesize.
---
### Pitfall 7: PCM Sample Overflow Produces Wrap-Around Distortion
### Pitfall A7: Config Auto-Discovery Follows Wrong Order or Ignores XDG Variables
**What goes wrong:**
Synthesizing audio as `int16` samples and summing multiple sine layers without clamping causes integer overflow. The value wraps around (e.g., 32767 + 100 = -32667 in int16), producing a sharp click or a buzzing distortion that corrupts the ambient soundscape. This is not clipping — it is a distinctly worse artifact.
The spec calls for auto-discovery from `./netsynth.toml` then `~/.config/netsynth/config.toml`. A naive implementation uses `os.UserHomeDir()` to build the fallback path. On systems where `$XDG_CONFIG_HOME` is set to a non-default location (common on NixOS, custom dotfile managers, CI environments), the tool ignores the user's configured config directory and looks in `~/.config` anyway. The user has a config at `$XDG_CONFIG_HOME/netsynth/config.toml` that is never found.
Additionally, `os.UserHomeDir()` returns an error if `$HOME` is unset (e.g., inside some Docker containers or cron jobs). If this error is not handled, the path construction silently produces `"/.config/netsynth/config.toml"` (an absolute path starting with `/.config`) rather than failing with a useful message.
**Why it happens:**
Developers model audio math in their head as real-valued floats, implement it in int16 for "efficiency," and forget that Go integer overflow is undefined-behavior-free but still wraps. With 6-8 drone layers simultaneously active, summing them easily exceeds ±32767.
Go's `os.UserConfigDir()` already implements the XDG lookup (`$XDG_CONFIG_HOME``~/.config` on Linux, `~/Library/Application Support` on macOS). Most developers reach for `os.UserHomeDir()` + hardcoded `".config"` string because it is the first function they find in the stdlib.
**How to avoid:**
Synthesize internally in `float64` in the range `[-1.0, 1.0]`. Apply a normalisation/soft-limiter pass before converting to `int16` for encoding. Clamp before cast: `sample := int16(math.Max(-1.0, math.Min(1.0, floatSample)) * 32767)`. Never do mixed-type audio math that passes through int16 as an intermediate.
**Consequences:**
- User's config is silently ignored when `$XDG_CONFIG_HOME` is non-default
- Confusing behavior difference between development machines and CI
**Warning signs:**
- Audio synthesis structs storing amplitude as `int16` or `int32`
- Adding layer outputs with `+=` without a final normalisation step
- Distorted output that correlates with traffic spikes (more active layers = more overflow)
**Prevention:**
Use `os.UserConfigDir()` (stdlib, Go 1.13+) for the platform-appropriate config directory. This correctly respects `$XDG_CONFIG_HOME` on Linux and `APPDATA` on Windows (if ever relevant). The discovery order should be:
```go
func configSearchPaths() []string {
var paths []string
// 1. Current directory (highest precedence)
paths = append(paths, "netsynth.toml")
// 2. XDG/platform config dir
if cfgDir, err := os.UserConfigDir(); err == nil {
paths = append(paths, filepath.Join(cfgDir, "netsynth", "config.toml"))
}
return paths
}
```
If `--config` flag is set, use that path exclusively and return a clear error if the file is absent (do not fall through to auto-discovery when explicit path is provided).
**Detection:**
- Config not loaded on systems where `$XDG_CONFIG_HOME=/custom/path`
- Silent "no config found" behavior when a config clearly exists at the XDG path
**Phase to address:**
Audio synthesis phase — establish the internal sample representation as `float64` from the start.
Config loading phase. Implement the path discovery with `os.UserConfigDir()` from the start. Fix before the feature ships.
---
### Pitfall 8: Tone-per-Protocol Mapping Produces Perceptual Chaos
### Pitfall A8: Explicit --config Flag Does Not Error on Missing File
**What goes wrong:**
Assigning arbitrary frequencies to protocols (e.g., DNS=440 Hz, HTTPS=880 Hz, ICMP=1320 Hz, SSH=1760 Hz, 6 auto-clusters=random) creates a soundscape where all tones are in the same frequency range, fighting each other. At moderate traffic the result is an undifferentiated buzz rather than distinct recognizable layers.
When `--config path/to/file.toml` is specified, the user expects an error if the file does not exist. If the config loader falls through to auto-discovery when the explicit path is missing, or silently uses defaults, the user has no way to detect a typo in their `--config` argument. They run a session, get "unexpected" default sounds, and have no indication their config was never loaded.
**Why it happens:**
Developers choose frequencies programmatically (e.g., multiples of a base frequency) without considering auditory scene analysis — the human perceptual process by which listeners separate simultaneous sounds into distinct streams. Sounds too close in frequency mask each other.
Auto-discovery logic is convenient to write as "try these paths, use first found." Developers reuse this logic even for the `--config` code path.
**How to avoid:**
Space protocol tones across register bands: low drones (80-200 Hz) for high-volume background traffic (HTTPS bulk), mid tones (300-600 Hz) for control traffic (DNS, NTP), high tones (800-1600 Hz) for interactive protocols (SSH, ICMP). Use harmonic or musical intervals (octaves, fifths) rather than arithmetic spacing. Keep auto-cluster frequencies in the 200-500 Hz mid-range so they don't obscure the "signature" tones. Limit simultaneous active layers to avoid masking.
**Prevention:**
Separate the two code paths:
- `--config` specified → `os.Open(flagValue)`, return error immediately if `errors.Is(err, os.ErrNotExist)`
- No flag → `configSearchPaths()` loop, silently skip missing files, proceed with defaults if none found
**Warning signs:**
- Frequency assignments as an arithmetic sequence: `baseFreq + n*200`
- No perceptual test — only waveform-level correctness checks
- Auto-cluster frequencies chosen randomly from the full audible range
**Detection:**
- `--config missing.toml` runs without error, uses defaults
- User misses that their config file path has a typo
**Phase to address:**
Audio mapping / synthesis phase — the frequency mapping table should be designed up front with the perceptual goals in mind, not patched after "it sounds like noise" feedback.
Config loading phase. A one-line `if flagValue != "" { /* require it */ }` branch is sufficient.
---
### Pitfall 9: Time Window Too Short — Unstable, Jittery Audio
### Pitfall A9: User Rules That Target the Same Port as Built-in Rules Are Silently Shadowed
**What goes wrong:**
Aggregating traffic into windows shorter than ~500ms causes rapid amplitude oscillation in the synthesized drones. A single ICMP ping becomes a brief tone burst; a DNS query causes a momentary volume spike. The output sounds jittery and event-driven rather than ambient.
A user writes a rule for `{Protocol: "tcp", DstPort: 443, Class: "my-api"}` intending to reclassify their internal HTTPS traffic. If built-in `ClassHTTPS` still appears before the user rule in the merged slice, the built-in rule wins every time. The user's intent ("I want my port-443 traffic to sound different") is silently defeated.
**Why it happens:**
Developers choose a "natural" update interval (100ms or 200ms matches CPU scheduling intuition) without considering audio envelope times. Human perception of tonal stability requires note durations of at least 200-500ms; drones need even longer.
First-match-wins with `SpecificRules + userRules + CatchAllRules` means built-in specific rules still precede user rules. A user trying to *override* a built-in mapping must replace it, not add after it.
**How to avoid:**
- Use a minimum window of 500ms for amplitude updates; 1-2s for tonal shifts
- Apply amplitude smoothing (exponential moving average with a decay of ~2-5s) so a single-packet burst doesn't cause an immediate amplitude jump
- Separate the "data collection" window (can be shorter) from the "audio parameter update" window (should be longer)
**Consequences:**
- User's specific rule is unreachable if a built-in rule covers the same port/protocol
- No error, no warning
- Functionally the same as Pitfall A5 but for specific (non-catch-all) built-in rules
**Warning signs:**
- `time.Tick(100 * time.Millisecond)` driving audio parameter updates
- No smoothing/interpolation between amplitude values
- Testing with ping floods (bursty) rather than continuous traffic
**Prevention:**
Two viable strategies:
1. **User rules first:** `userRules + specificDefaultRules + catchAllRules`. User rules always take precedence. Built-ins serve as fallback. This is the simplest design and most aligned with user expectations ("I configure what I care about; defaults handle everything else").
2. **Conflict detection:** After merging, scan for duplicate `(protocol, dstPort)` pairs and emit a warning: `"User rule for tcp:443 shadows built-in HTTPS rule. Did you mean to replace it?"`.
Option 1 is recommended for simplicity. Document it clearly: "User-defined rules are evaluated before built-in rules."
**Detection:**
- User-defined rule for a built-in port (80, 443, 22, etc.) never activates
- Verbose output shows built-in class instead of user class for the expected traffic
**Phase to address:**
Traffic aggregation / audio mapping phase — establish the window and smoothing strategy before wiring traffic data to audio parameters.
User-defined rules phase, merge strategy design. Address at the same time as Pitfall A5.
---
## Technical Debt Patterns
### Pitfall A10: New TrafficClass Strings From Config Are Not Validated — Empty String or Whitespace Is a Valid Key
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| `google/gopacket` instead of `gopacket/gopacket` | Familiar, more tutorials | Unmaintained; Go compat breaks | Never |
| `sudo ./netsynth` only, no `setcap` docs | Simpler setup instructions | Users won't run as root in practice; tool appears broken | MVP only — document the limitation |
| Dynamic libpcap linking (no static build) | Faster to compile | Binary doesn't work on target machines without libpcap installed | Only acceptable if distributing via package manager that declares the dep |
| `ReadPacketData` (copying) instead of `ZeroCopy` | Safe, simple | ~20% memory overhead at high packet rates | Always acceptable; optimize only if profiling proves allocation bottleneck |
| Sine-wave-only synthesis (no ADSR, no envelope) | Much simpler code | Tonal changes are abrupt, not perceptually smooth | Acceptable for v1 ambient/drone if EMA smoothing is applied to amplitude |
| Hard-coded frequency table (no config) | No CLI complexity | Can't tune without recompiling | Acceptable for v1 per PROJECT.md out-of-scope decision |
**What goes wrong:**
A user writes:
```toml
[[rules]]
protocol = "tcp"
dst_port = 9200
class = ""
```
The string `""` decodes without error. It is a valid Go map key. It gets inserted into the `WindowSnapshot.Counts` map and the aggregator increments `Counts[""]`. The bank has no layer for `""`. The behavior is undefined — silent or panic depending on nil-guard presence.
Similarly, `class = " elasticsearch "` (padded spaces) decodes to a string with leading/trailing whitespace that does not match any configured sound entry (because the config sound entry key is `"elasticsearch"` without spaces).
**Prevention:**
Validate all `Class` string values from user rules in the `validate()` step:
```go
if strings.TrimSpace(rule.Class) == "" {
return fmt.Errorf("rule %d: class name must not be empty", i)
}
rule.Class = strings.TrimSpace(rule.Class)
```
Also validate that class names do not collide with reserved built-in class names (`"ICMP"`, `"DNS"`, etc.) unless the user is explicitly overriding a built-in sound (which is a distinct feature — it should be opt-in, not accidental).
**Phase to address:**
Config validation step.
---
## Integration Gotchas
## v1.0 Pitfalls (Retained for Reference)
The following pitfalls from the initial MVP research remain valid. They are retained in condensed form for reference.
---
### Pitfall B1: Using `google/gopacket` Instead of the Active Community Fork
**What goes wrong:** Import of the unmaintained original — 270 open issues, Go compat degrades.
**Prevention:** Import `github.com/gopacket/gopacket` (v1.5.0, requires Go 1.24+).
**Phase:** Phase 1 — set correct import path from day one.
---
### Pitfall B2: CGo Destroys the "Single Binary" Promise
**What goes wrong:** `gopacket/pcap` (CGo + libpcap) produces a dynamically-linked binary that fails on machines without `libpcap.so`.
**Prevention:** Use `packetcap/go-pcap` (pure Go capture, already the chosen stack). Verify with `ldd ./netsynth`.
**Phase:** Phase 1 — foundational architecture decision.
---
### Pitfall B3: `CAP_NET_RAW` + Binary Location = Silent Failure on Linux
**What goes wrong:** `setcap` is silently ignored on `nosuid` filesystems. Binary appears broken from home directories.
**Prevention:** Install to `/usr/local/bin`; document two run modes; emit clear privilege error.
**Phase:** Phase 1 + CLI UX.
---
### Pitfall B4: Packet Buffer Overflow Under Moderate Traffic Load
**What goes wrong:** Default capture buffer fills faster than the classifier consumes it; silent packet drops misrepresent traffic.
**Prevention:** Large capture buffer (32 MB); buffered channel between capture and classify goroutines.
**Phase:** Phase 1/2 (capture pipeline architecture).
---
### Pitfall B5: ZeroCopy Packet Data Use-After-Free
**What goes wrong:** `ZeroCopyReadPacketData()` invalidates previous slice on each call; silent data corruption in concurrent code.
**Prevention:** Use `ReadPacketData()` (copying API) unless profiling proves allocation bottleneck.
**Phase:** Phase 1 (capture/decode).
---
### Pitfall B6: MP3 Output Is Corrupt Due to LAME Initialization Order
**What goes wrong:** Skipping `InitParams()` or setting parameters out of order produces unplayable MP3.
**Prevention:** Always call `InitParams()` before writing frames; smoke test with `ffprobe`.
**Phase:** Audio synthesis / encoding phase.
---
### Pitfall B7: PCM Sample Overflow Produces Wrap-Around Distortion
**What goes wrong:** Summing `int16` layers overflows and wraps (32767 + 100 = -32667), producing buzzing distortion.
**Prevention:** Synthesize in `float64 [-1.0, 1.0]`; clamp before int16 cast. Already implemented in `synth/mixer.go`.
**Phase:** Audio synthesis (already addressed in v1.0).
---
### Pitfall B8: Tone-per-Protocol Mapping Produces Perceptual Chaos
**What goes wrong:** Frequencies too close together mask each other; output is undifferentiated buzz.
**Prevention:** Space protocols across register bands; use harmonic/musical intervals. Already addressed in v1.0.
**Phase:** Audio mapping (already addressed in v1.0).
---
## v1.1 Phase-Specific Warnings
| Phase Topic | Likely Pitfall | Mitigation |
|-------------|---------------|------------|
| TOML struct design | A1: zero-value overwrites defaults | Use pointer fields for all optional overrides |
| Config strict decode | A2: typos silently ignored | Use `md.Undecoded()` as strict mode check |
| Waveform implementation | A3: naive waveform aliases | Use additive synthesis (bandlimited harmonic series) — compatible with existing `[]HarmonicDef` API |
| Waveform string input | A4: case/abbreviation mismatches | Normalize + validate with clear error listing accepted values |
| Rule merge ordering (catch-alls) | A5: user rules after catch-alls are unreachable | Split `DefaultRules` into `SpecificRules` + `CatchAllRules`; user rules go in between |
| Bank construction | A6: custom class has no synth layer | Derive full class set from merged rule slice; pass to bank constructor |
| Config discovery | A7: XDG ignored, `~/.config` hardcoded | Use `os.UserConfigDir()` not `os.UserHomeDir() + "/.config"` |
| --config flag path | A8: missing explicit path silently ignored | Two distinct code paths: flag path (require) vs auto-discovery (skip-missing) |
| Rule merge ordering (specific built-ins) | A9: user rule shadowed by built-in for same port | User rules first in merged slice (`userRules + specificDefaults + catchAlls`) |
| Class name validation | A10: empty/whitespace class name is valid Go string | Validate and trim all class strings in `validate()` |
---
## Integration Gotchas (v1.1 Additions)
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| `gopacket/pcap` handle | Not calling `handle.Close()` on signal — leaks capture resources | Use `defer handle.Close()` and ensure the goroutine exits before process termination |
| LAME CGo encoder | Not flushing the encoder before closing — truncated final MP3 frame | Call `encoder.Flush()` / `lame.EncodeFlush()` after the sample loop ends |
| OS signal handling (`SIGINT`) | Goroutine receives SIGINT but the capture loop is blocked on `ReadPacketData` | Use `handle.SetReadDeadline(time.Now())` or close the handle to unblock |
| MP3 encoder sample format | Passing `float64` samples directly to LAME (expects `int16` or `float32` depending on binding) | Explicitly convert and clamp to the binding's expected type; check each binding's API |
| `pcapgo.EthernetHandle` | Only captures Ethernet frames — fails on WiFi (802.11), loopback, or tunnel interfaces | For non-Ethernet interfaces, use the `pcap` backend or check link type at startup |
---
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Single goroutine: capture + classify + synthesize | CPU-bound synthesis blocks packet reads; drops spike under any real traffic | Three-stage pipeline: capture goroutine → classify channel → synthesis goroutine | Breaks on any network with > ~1000 pps |
| One goroutine per packet | Goroutine creation overhead exceeds packet processing time; OOM on busy networks | Channel-based batching: one reader, N classifiers from a worker pool | Breaks above ~10k pps |
| Recomputing sine wave sample-by-sample in inner loop using `math.Sin` | CPU pegged at 100% during synthesis; output can't keep pace | Precompute wavetable per frequency; iterate with phase accumulator | Breaks with > 4-5 simultaneous drone layers at 44100 Hz |
| Blocking channel between capture and synthesis with no buffer | Any synthesis stall causes packet drops | Buffered channel of 1000+ packets; separate goroutines | Breaks immediately on any CPU scheduling hiccup |
---
## Security Mistakes
| Mistake | Risk | Prevention |
|---------|------|------------|
| Requesting full `root` and keeping it throughout capture | Privilege escalation if a parsing bug in gopacket can be exploited via crafted packets | Drop privileges after opening the capture handle: `syscall.Setuid(originalUID)` |
| Promiscuous mode on by default without user opt-in | Captures all LAN traffic, not just traffic to/from the host — legal and privacy risk on shared networks | Default to non-promiscuous; add `--promiscuous` flag with a warning message |
| No limit on capture duration or file size | Unbounded run produces an arbitrarily large MP3 or consumes all memory in the aggregator maps | Add `--max-duration` flag (default: warn at 10min, hard limit at 1hr); prune old flow state periodically |
| Logging decoded packet payloads in debug mode | Inadvertently logs credentials or private data | Never log packet payload bytes; log only headers and metadata |
---
## UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Silent failure when interface doesn't exist | User specifies `-i eth1` on a machine with only `ens3`; tool exits with cryptic libpcap error | List available interfaces at startup with `pcap.FindAllDevs()` and suggest correct name |
| No progress feedback during capture | User has no idea if the tool is working; assumes it hung | Print periodic status line: "Capturing... 1,234 packets classified (HTTPS:45% DNS:30% ICMP:8% other:17%)" |
| Output MP3 path collision without warning | Re-running overwrites previous output | Warn if output file exists; suggest timestamped default filename |
| Ctrl+C produces empty or invalid MP3 | User interrupts too quickly before any traffic is captured | Detect zero-packet case and emit an error instead of an empty file |
| No indication of which interface is being captured | Confusing when multiple interfaces exist | Print "Capturing on: eth0 (192.168.1.5)" at startup |
---
## "Looks Done But Isn't" Checklist
- [ ] **Packet capture:** Binary runs as non-root user with `setcap` — verify from a non-`/home` path, not just from the build directory
- [ ] **MP3 output:** File validates with `ffprobe` or `mp3val` — not just "has .mp3 extension and non-zero size"
- [ ] **Static binary:** `ldd ./netsynth` shows "not a dynamic executable" (or explicitly "requires libpcap" if dynamic is accepted)
- [ ] **Signal handling:** Ctrl+C during capture produces a valid (playable) MP3, not a truncated file
- [ ] **High-traffic:** Drop counter is zero (or documented/acceptable) when tested against a network with > 1000 pps
- [ ] **Audio layers:** Output with 6+ simultaneous traffic types does not distort — no wrap-around clipping audible
- [ ] **Empty capture:** Graceful error message when zero packets were captured, not a silent empty file
- [ ] **Interface not found:** Helpful error with available interface list, not a libpcap raw error string
---
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| Wrong gopacket fork | LOW | `go mod edit -replace github.com/google/gopacket=github.com/gopacket/gopacket@v1.5.0`; update import paths |
| Dynamic binary on clean machine | MEDIUM | Add static build Makefile target; update CI; update README |
| PCM overflow / distortion | LOW | Refactor synthesis to float64 internal representation; add clamp before int16 cast |
| Corrupt MP3 (missing flush) | LOW | Add `Flush()` call in the shutdown path |
| Perceptual chaos (tone mapping) | MEDIUM | Redesign frequency table (no code change to synthesis engine); requires subjective listening tests |
| Time window jitter | LOW | Add EMA smoothing and increase window; no architectural change needed |
| `ZeroCopy` data corruption | MEDIUM | Replace `ZeroCopyReadPacketData` with `ReadPacketData`; audit all goroutine handoffs |
---
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| Wrong gopacket fork | Phase 1: Packet Capture | `go.mod` references `gopacket/gopacket`; `go list -m github.com/gopacket/gopacket` |
| CGo / single binary contract | Phase 1: Packet Capture | `ldd` output on CI; test on clean Alpine container |
| CAP_NET_RAW binary location | Phase 1 + CLI UX phase | Test `setcap` from `/usr/local/bin`; verify helpful error message from non-root |
| Packet buffer overflow | Phase 1/2: Capture Pipeline | `SetBufferSize` call present; goroutine architecture is async (channel-separated) |
| ZeroCopy use-after-free | Phase 1: Capture/Decode | Code review: no `ZeroCopy` passed to goroutines without copy; or use `ReadPacketData` |
| LAME init errors / corrupt MP3 | Audio synthesis phase | CI smoke test: 1s silence → `ffprobe` validates output file |
| PCM overflow wrap-around | Audio synthesis phase | Unit test: 8 simultaneous max-amplitude layers produce no distortion |
| Perceptual tone chaos | Audio mapping phase | Subjective listen test with mixed traffic capture; frequency table reviewed against auditory masking |
| Time window jitter | Traffic aggregation / mapping phase | Capture test with bursty traffic; verify EMA smoothing produces stable amplitude |
| Config → Bank wire-up | Pass `classify.AllClasses()` to bank; custom classes missing | Derive layer set from `Classifier.ActiveClasses()` — all classes reachable via the effective rule set |
| Waveform → FreqConfig | Add `WaveformType string` to `FreqConfig`; forget to generate harmonics at bank init | Generate `[]HarmonicDef` from waveform+freq at bank/layer construction time, not at sample render time |
| User rules → Classifier | Replace `DefaultRules` var directly; breaks tests relying on it | Keep `DefaultRules` immutable; construct `mergedRules` for runtime use |
| Config file absent | Return error if no config found | Return nil (no config = all defaults). Only error on explicit `--config` path that is missing |
| Sound overrides for built-in class | User sets freq for "HTTPS" — must hit `ClassHTTPS` layer | Match config sound keys case-insensitively against `TrafficClass` string values; map `"HTTPS"``classify.ClassHTTPS` |
---
## Sources
- [gopacket/gopacket (community fork, v1.5.0)](https://github.com/gopacket/gopacket) — active fork status
- [google/gopacket issue #329: 98% packet loss under high traffic](https://github.com/google/gopacket/issues/329) — buffer overflow and afpacket solution
- [google/gopacket issue #1016: current project status](https://github.com/google/gopacket/issues/1016) — unmaintained status of original repo
- [google/gopacket issue #1167: static linking libpcap.a](https://github.com/google/gopacket/issues/1167) — static build complications
- [ZeroCopyReadPacketData docs (gopacket/pcap)](https://pkg.go.dev/github.com/google/gopacket/pcap) — memory ownership contract
- [linuxvox.com: CAP_NET_RAW outside /usr/bin](https://linuxvox.com/blog/raw-capture-capabilities-cap-net-raw-cap-net-admin-not-working-outside-usr-bin-and-friends-for-packet-capture-program-using-libpcap/) — nosuid and AppArmor restrictions
- [braheezy.github.io: What I Learned About MP3 Encoding](https://braheezy.github.io/posts/what-i-learned-about-mp3-encoding/) — Go MP3 encoding pitfalls
- [github.com/braheezy/shine-mp3](https://github.com/braheezy/shine-mp3) — pure Go MP3 encoder (no CGo)
- [Eli Bendersky: Building Static Binaries with Go on Linux](https://eli.thegreenplace.net/2024/building-static-binaries-with-go-on-linux/) — CGo static linking strategy
- [SoNSTAR: Sonification of Networks for Situational Awareness](https://github.com/nuson/SoNSTAR) — reference architecture for network sonification
- [PLOS One: Sonification of Network Traffic Flow](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0195948) — time window and design lessons
- [KVR Audio: PCM float-to-int clipping and wrap-around](https://www.kvraudio.com/forum/viewtopic.php?t=414666) — PCM overflow consequences
- [bjornroche.com: ABCs of PCM Digital Audio](http://blog.bjornroche.com/2013/05/the-abcs-of-pcm-uncompressed-digital.html) — sample format fundamentals
- [BurntSushi/toml pkg.go.dev](https://pkg.go.dev/github.com/BurntSushi/toml) — `Undecoded()` strict mode, pointer field behavior, `MetaData` API
- [BurntSushi/toml issue #47: Unmarshal with default values](https://github.com/BurntSushi/toml/issues/47) — confirms default-overwrite behavior
- [pelletier/go-toml issue #252: Unmarshal overrides origin values if key is omitted](https://github.com/pelletier/go-toml/issues/252) — confirms same behavior in v1; v2 partially resolves
- [pelletier/go-toml v2 pkg.go.dev](https://pkg.go.dev/github.com/pelletier/go-toml/v2) — strict decoder mode documentation
- [golang/go issue #29960: os: add UserConfigDir](https://github.com/golang/go/issues/29960) — rationale for `os.UserConfigDir()` (XDG-aware)
- [WolfSound: Basic Waveforms in Synthesis](https://thewolfsound.com/sine-saw-square-triangle-pulse-basic-waveforms-in-synthesis/) — aliasing and harmonic series for square/saw/triangle
- [CCRMA: Alias-Free Digital Synthesis of Classic Analog Waveforms](https://ccrma.stanford.edu/~stilti/papers/blit.pdf) — bandlimited synthesis theory
- [McGill Bandlimited Synthesis of Classic Waveforms](https://www.music.mcgill.ca/~gary/307/week5/bandlimited.html) — truncated harmonic series approach
- [Teensy Forum: triangle & sawtooth oscillators aliasing](https://forum.pjrc.com/threads/61269-triangle-amp-sawtooth-oscillators-how-to-deal-with-aliasing) — practical aliasing impact at different frequencies
- [adrg/xdg package](https://github.com/adrg/xdg) — XDG Base Directory Specification Go implementation (reference; stdlib `os.UserConfigDir()` is sufficient for NetSynth's needs)
---
*Pitfalls research for: network-traffic-to-audio synthesis CLI (Go) — NetSynth*
*Researched: 2026-03-24*
*Pitfalls research for: NetSynth v1.1 — TOML config, waveform types, user-defined rules*
*Updated: 2026-03-26*
+180 -131
View File
@@ -1,166 +1,215 @@
# Stack Research
# Technology Stack
**Domain:** Go CLI tool — network packet capture, traffic classification, audio synthesis, MP3 encoding
**Researched:** 2026-03-24
**Confidence:** MEDIUM-HIGH (packet capture and CLI: HIGH; audio synthesis in Go: MEDIUM; MP3 encoding: MEDIUM)
**Project:** NetSynth v1.1 — Custom Sound Mappings
**Researched:** 2026-03-26
**Scope:** Additions/changes only. Existing stack (gopacket, go-pcap, go-lame, cobra) is validated and unchanged.
---
## Recommended Stack
## Existing Stack (Do Not Re-research)
### Core Technologies
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| `github.com/gopacket/gopacket` | v1.5.0 | Packet capture, protocol decoding | The canonical Go packet library. Community fork (`gopacket/gopacket`) supersedes the original Google repo (`google/gopacket`) as of 2024; released v1.5.0 in November 2025, minimum Go 1.24. 14.5k dependents; has ICMP, TCP, UDP, DNS, TLS layer decoders built in. |
| `github.com/packetcap/go-pcap` | v0.0.0-20251215 | Pure-Go live packet capture backend | Replaces CGo libpcap dependency for live capture. 100% native Go, Linux + macOS, mmap-based kernel ring buffer for performance. Implements the `gopacket.PacketDataSource` interface so gopacket decodes packets on top of it. Enables CGO_ENABLED=0 builds and cross-compilation. |
| `github.com/sjzar/go-lame` | v0.0.9 | MP3 encoding | Embeds libmp3lame C source directly via CGo — no external `libmp3lame` system package required. Published April 2025. Exposes sample rate, channels, quality control. Produces LAME-quality MP3, unlike the pure-Go shine-mp3 port which produces larger, lower-quality output. Tradeoff: requires CGo, so `CGO_ENABLED=1` and a C compiler at build time. |
| `github.com/spf13/cobra` | v1.10.2 | CLI flag parsing and command structure | The industry standard for Go CLIs (Kubernetes, Docker, Hugo, etc.). v1.10.2 released December 2025. Handles `--interface`, `--output` flags, Ctrl+C signal plumbing, and `--help` generation automatically. No alternatives worth considering for this scope. |
### Supporting Libraries
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `github.com/muesli/kmeans` | v0.3.1 | K-means clustering for unrecognized traffic patterns | Use to auto-cluster packets that don't match known protocol rules. Feed feature vectors: [port, protocol_num, packet_size_bin, direction]. Last release July 2022 but mathematically stable; the algorithm doesn't change. Alternatively, implement a simple incremental classifier directly (see Architecture notes below). |
| `github.com/go-audio/wav` | latest | WAV file I/O as intermediate format | Use to write synthesized PCM as WAV before MP3 encoding pass. "Battle tested" per maintainer. Simplifies the PCM → encoder pipeline: synthesize float64 samples → write WAV → re-read as PCM → LAME encode. |
| `golang.org/x/sys/unix` | stdlib | Raw socket / CAP_NET_RAW privilege checks | Use for detecting if the process has required privileges and for signaling (SIGINT for clean shutdown). Part of Go extended stdlib — no external version pinning needed. |
### Development Tools
| Tool | Purpose | Notes |
|------|---------|-------|
| `go build -ldflags="-s -w"` | Stripped binary production builds | Reduces binary size significantly; combine with `upx` if size is critical |
| `goreleaser` | Cross-platform release builds | Handles CGo cross-compilation complexity with Docker-based build matrix; useful for distributing Linux x86_64 + ARM64 binaries |
| `golangci-lint` | Static analysis | Catches nil pointer dereferences common in packet-handling code |
| Wireshark / `tcpdump` | Manual verification of packet capture | Essential for confirming gopacket is decoding the right protocols before plugging into audio synthesis |
| Technology | Version | Status |
|------------|---------|--------|
| `github.com/gopacket/gopacket` | v1.5.0 | Validated in v1.0, unchanged |
| `github.com/packetcap/go-pcap` | v0.0.0-20251215 | Validated in v1.0, unchanged |
| `github.com/sjzar/go-lame` | v0.0.9 | Validated in v1.0, unchanged |
| `github.com/spf13/cobra` | v1.10.2 | Validated in v1.0, unchanged |
| Hand-rolled sine oscillator + EMA | — | Validated in v1.0, extend in place |
| Ordered `[]Rule` classifier | — | Validated in v1.0, extend in place |
---
## Installation
## New Dependencies for v1.1
```bash
# Initialize module
go mod init netsynth
### TOML Config Parsing
# Core dependencies
go get github.com/gopacket/gopacket@v1.5.0
go get github.com/packetcap/go-pcap@latest
go get github.com/sjzar/go-lame@v0.0.9
go get github.com/spf13/cobra@v1.10.2
| Technology | Version | Purpose | Why |
|------------|---------|---------|-----|
| `github.com/BurntSushi/toml` | v1.6.0 | Parse `netsynth.toml` config files | Single-function `toml.Decode()` into a struct. The `MetaData.Undecoded()` method catches unknown keys in user configs — surfacing typos like `frequncy` rather than silently ignoring them. This is the right behavior for a config file tool. v1.6.0 released December 2025, Go 1.18+ required. Zero indirect dependencies. |
# Supporting
go get github.com/muesli/kmeans@v0.3.1
go get github.com/go-audio/wav@latest
**Version confirmed:** v1.6.0, December 18, 2025, via pkg.go.dev and GitHub releases page.
# Build (CGo required for go-lame)
CGO_ENABLED=1 go build -ldflags="-s -w" -o netsynth ./cmd/netsynth
```
**Why not `pelletier/go-toml v2`:** go-toml v2.3.0 (March 2026) is faster but the performance difference is irrelevant — config is read once at startup. go-toml v2's `Strict` mode can detect unknown keys but requires more setup than BurntSushi's `MetaData.Undecoded()`. BurntSushi's API is simpler for this use case and has clearer error message patterns for user-facing config mistakes.
**Root/privilege requirement at runtime (not build time):**
```bash
sudo ./netsynth --interface eth0 --output traffic.mp3
# OR: grant capability instead of running as root
sudo setcap cap_net_raw+ep ./netsynth
```
### Config Auto-Discovery
---
No new dependency. Use Go stdlib only:
## Alternatives Considered
| Recommended | Alternative | When to Use Alternative |
|-------------|-------------|-------------------------|
| `gopacket/gopacket` (community fork) | `google/gopacket` (original) | Never for new projects — original repo has 270 open issues, community fork actively merges fixes |
| `packetcap/go-pcap` (pure Go) | `gopacket/pcap` (CGo + libpcap) | Use libpcap path only if you need advanced BPF filter syntax or BSD/Windows support — it requires `libpcap-dev` system package |
| `sjzar/go-lame` (embedded C source) | `braheezy/shine-mp3` (pure Go) | Use shine-mp3 if CGo is truly impossible (e.g., WASM target) — but accept that output quality and file size are worse |
| `sjzar/go-lame` (embedded C source) | `viert/go-lame` (dynamic link) | Never — viert/go-lame requires libmp3lame installed on the target system, defeating single-binary distribution |
| Hand-rolled additive synthesis | `dasa.cc/snd`, `bspaans/bleep` | Use a library only if you need MIDI scheduling or real-time playback; for file output, the synthesis math is simple enough to own directly (see Architecture notes) |
| `muesli/kmeans` | `mpraski/clusters` | Use mpraski if you need online (incremental) clustering — it supports add-one-point updates vs muesli's batch-only approach |
---
## What NOT to Use
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `google/gopacket` (original) | Effectively unmaintained since 2022; 270 open issues, PRs not merged | `github.com/gopacket/gopacket` (community fork, v1.5.0) |
| `viert/go-lame` or `sunicy/go-lame` | Dynamic-links against system `libmp3lame` — breaks single-binary distribution, fails on machines without the library | `github.com/sjzar/go-lame` (embeds C source statically) |
| `braheezy/shine-mp3` (pure Go MP3) | Last commit 2023, explicitly not production-ready per its own README, produces larger lower-quality files, no bitrate control | `sjzar/go-lame` for quality, or WAV output if you must avoid CGo |
| `go-audio/generator` | **Archived February 2026, read-only** — do not take a new dependency on it | Write your own oscillator (20 lines of Go) or use `dasa.cc/snd` |
| `faiface/beep` | Designed for real-time audio playback via PortAudio/oto; pulls in platform audio drivers that are irrelevant for file output | Roll a minimal additive synthesizer directly (see below) |
| `dasa.cc/snd` | Plays audio through hardware; brings in real-time audio scheduling complexity unnecessary for batch file output | Roll a minimal additive synthesizer directly |
| urfave/cli | Fine for simpler tools, but Cobra's flag validation, help generation, and signal handling are better for a tool with multiple flags and clean shutdown semantics | `github.com/spf13/cobra` |
---
## Stack Patterns by Variant
**If CGo is acceptable (recommended path):**
- Use `sjzar/go-lame` for real MP3 quality
- Use `packetcap/go-pcap` for the capture layer (pure Go on Linux/macOS)
- Build with `CGO_ENABLED=1`; single binary is still self-contained because LAME C source is embedded
**If pure Go / no CGo is required (e.g., restricted build environment):**
- Use `braheezy/shine-mp3` for MP3 — accept lower quality and larger files
- Use `packetcap/go-pcap` for capture — already pure Go
- Build with `CGO_ENABLED=0`; truly static binary
**If Linux-only deployment is acceptable:**
- Consider `packetcap/go-pcap`'s mmap ring buffer mode for high-traffic interfaces (default on Linux)
- Privilege: `CAP_NET_RAW` setcap is cleaner than running as root
**For the audio synthesis layer — roll your own, don't use a library:**
The ambient/drone requirement is additive synthesis: N sine wave oscillators, each with a frequency and time-varying amplitude. This is 30-50 lines of Go:
```go
// Conceptual — not a library call
for t := 0; t < numSamples; t++ {
sample := 0.0
for _, layer := range layers {
sample += layer.Amplitude(t) * math.Sin(2*math.Pi*layer.Freq*float64(t)/sampleRate)
// Probe order: --config flag > ./netsynth.toml > ~/.config/netsynth/config.toml
func findConfigPath(flagValue string) (string, bool) {
if flagValue != "" {
return flagValue, true
}
pcm[t] = int16(sample * 32767)
if _, err := os.Stat("./netsynth.toml"); err == nil {
return "./netsynth.toml", true
}
if dir, err := os.UserConfigDir(); err == nil {
p := filepath.Join(dir, "netsynth", "config.toml")
if _, err := os.Stat(p); err == nil {
return p, true
}
}
return "", false
}
```
No library adds value here. Libraries designed for real-time playback add complexity (audio thread management, ring buffers, OS audio drivers) that hurts a batch file-output tool.
`os.UserConfigDir()` returns `$XDG_CONFIG_HOME` if set, else `$HOME/.config` on Linux/macOS — confirmed against Go stdlib docs. No third-party XDG library needed.
### Additional Waveform Types
No new dependency. Extend the existing `synth.Oscillator` in place.
Square, sawtooth, and triangle are pure math — each is ~3 lines. The existing oscillator uses a phase accumulator (0.01.0 range), which is the right representation for all four waveforms:
```go
// Waveform enum addition to synth package
type Waveform int
const (
WaveformSine Waveform = iota
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
// Per-sample generation (replaces math.Sin call in Advance())
func sample(phase float64, w Waveform) float64 {
switch w {
case WaveformSquare:
if phase < 0.5 { return 1.0 }
return -1.0
case WaveformSawtooth:
return 2*phase - 1.0
case WaveformTriangle:
if phase < 0.5 { return 4*phase - 1.0 }
return 3.0 - 4*phase
default: // WaveformSine
return math.Sin(2 * math.Pi * phase)
}
}
```
The `Oscillator` struct gains a `Waveform` field; `Advance()` dispatches to `sample()`. Harmonics still work the same way — each harmonic's phase is `phase * ratio`, which maps correctly for all waveform types.
---
## Installation Delta
```bash
# Add only this new dependency
go get github.com/BurntSushi/toml@v1.6.0
```
No changes to build flags. `CGO_ENABLED=1` still required for go-lame.
---
## Integration Points
### Where Config Feeds Existing Code
The TOML config needs to override two existing data structures:
1. **`synth.ClassFreqConfigs`** (map in `synth/config.go`) — user can override `BaseHz` and add a `Waveform` field per class
2. **`classify.DefaultRules`** (slice in `classify/rules.go`) — user can prepend custom rules before the defaults
The config loader should apply overrides at startup before any other initialization. The cleanest integration is:
```
cmd/netsynth/main.go
-> config.Load(path) // returns *AppConfig
-> classify.MergeRules(cfg) // prepend user rules to DefaultRules
-> synth.ApplyOverrides(cfg) // patch ClassFreqConfigs entries
```
Both `classify.DefaultRules` and `synth.ClassFreqConfigs` are currently package-level vars — they can be replaced or cloned at startup without changing the downstream pipeline.
### TOML Struct Shape
The config schema maps naturally to the existing types:
```toml
# netsynth.toml
[[rules]]
protocol = "tcp"
dst_port = 8443
class = "my-https-alt"
[sounds.my-https-alt]
frequency = 195.0
waveform = "square"
[sounds.ICMP]
frequency = 80.0 # override built-in
waveform = "triangle"
```
```go
type AppConfig struct {
Rules []RuleConfig `toml:"rules"`
Sounds map[string]SoundConfig `toml:"sounds"`
}
type RuleConfig struct {
Protocol string `toml:"protocol"`
DstPort uint16 `toml:"dst_port"`
Class string `toml:"class"`
}
type SoundConfig struct {
Frequency float64 `toml:"frequency"`
Waveform string `toml:"waveform"` // "sine"|"square"|"sawtooth"|"triangle"
}
```
Use `toml.Decode()` and check `meta.Undecoded()` to warn on unknown keys.
---
## What NOT to Add
| Avoid | Why | What to Do Instead |
|-------|-----|-------------------|
| `adrg/xdg` or any XDG library | `os.UserConfigDir()` in stdlib already handles `$XDG_CONFIG_HOME` on Linux — confirmed | Use `os.UserConfigDir()` directly |
| `pelletier/go-toml v2` | No advantage over BurntSushi for a single-file startup read; `MetaData.Undecoded()` in BurntSushi is more ergonomic for typo detection | `github.com/BurntSushi/toml` |
| `spf13/viper` | Massive dependency (brings in 20+ transitive deps) for a use case that is one TOML file — Viper adds remote config, env var binding, hot reload, none of which are needed | `BurntSushi/toml` + manual flag override |
| Any waveform/audio library | Square/sawtooth/triangle are 3 lines of math each; no library adds value | Extend `synth.Oscillator` in place |
| `gopkg.in/yaml.v3` or JSON config | TOML is explicitly specified for this milestone and is the right format for user-editable config files (comments supported, less noisy than JSON) | TOML only |
---
## Version Compatibility
| Package | Compatible With | Notes |
|---------|-----------------|-------|
| `gopacket/gopacket@v1.5.0` | Go 1.24+ | v1.5.0 bumped minimum Go to 1.24; use Go 1.24.x toolchain |
| `packetcap/go-pcap` | Linux, macOS (Darwin) | No Windows support; this is acceptable per project constraints |
| `sjzar/go-lame@v0.0.9` | Any Go + C compiler; CGO_ENABLED=1 | Embeds LAME C source; no system library dependency |
| `spf13/cobra@v1.10.2` | Go 1.20+ | No issues with Go 1.24 |
| `muesli/kmeans@v0.3.1` | Go 1.12+ | Stable; no compatibility concerns |
| Package | Version | Compatible With | Notes |
|---------|---------|-----------------|-------|
| `BurntSushi/toml` | v1.6.0 | Go 1.18+ | No issues with Go 1.24 |
| `os.UserConfigDir()` | stdlib | Go 1.13+ | Returns `$XDG_CONFIG_HOME` or `$HOME/.config` on Linux |
---
## Audio Synthesis Architecture Note
## Confidence Assessment
Do not reach for an audio library. The synthesis requirement is:
1. Map each traffic class (ICMP, DNS, HTTPS, SSH, unknown-cluster-N) to a base frequency
2. Accumulate packet counts per class per time window (e.g., 500ms buckets)
3. Drive oscillator amplitude from smoothed packet rate (exponential moving average)
4. Sum N oscillators into PCM samples at 44100 Hz, 16-bit, mono
5. Write PCM to WAV via `go-audio/wav`, then encode WAV to MP3 via `sjzar/go-lame`
The WAV intermediate step decouples synthesis from encoding and gives you a debug artifact. Total synthesis code: ~100 lines. No external library needed.
| Area | Confidence | Source |
|------|------------|--------|
| BurntSushi/toml v1.6.0 version | HIGH | pkg.go.dev confirmed, GitHub releases confirmed |
| `os.UserConfigDir()` XDG behavior | HIGH | Official Go stdlib docs at pkg.go.dev/os |
| Waveform math (no library needed) | HIGH | Trivial math, Dylan Meeus Go audio blog confirms the same approach |
| go-toml v2.3.0 version | HIGH | pkg.go.dev confirmed |
| Recommendation of BurntSushi over go-toml v2 | MEDIUM | Based on API ergonomics for the specific `Undecoded()` use case; both would work |
---
## Sources
- `github.com/gopacket/gopacket` releases page — v1.5.0 confirmed, November 2025
- `pkg.go.dev/github.com/packetcap/go-pcap` — v0.0.0-20251215, pure Go, Linux/macOS confirmed
- `pkg.go.dev/github.com/sjzar/go-lame` — v0.0.9, April 2025, embedded C source confirmed
- `pkg.go.dev/github.com/spf13/cobra` — v1.10.2, December 2025
- `github.com/go-audio/generator` — archived February 2026 (read-only), do not use
- `braheezy.github.io/posts/what-i-learned-about-mp3-encoding/` — author's first-hand account of Go MP3 encoding options, concluded shine-mp3 is not production-grade
- `github.com/google/gopacket/issues/1016` — maintenance status discussion confirming community fork is preferred
- WebSearch: muesli/kmeans v0.3.1 last release July 2022 — LOW confidence on ongoing maintenance, but algorithm is stable
- WebSearch: cobra v1.9.1/v1.10.2 — MEDIUM confidence, confirmed via pkg.go.dev
- `pkg.go.dev/github.com/BurntSushi/toml` — v1.6.0 confirmed, December 18, 2025
- `github.com/BurntSushi/toml/releases` — v1.6.0 release notes, TOML 1.1 enabled by default
- `pkg.go.dev/github.com/pelletier/go-toml/v2` — v2.3.0 confirmed, March 24, 2026
- `pkg.go.dev/os#UserConfigDir` — XDG_CONFIG_HOME behavior on Linux confirmed via official Go docs
- `dylanmeeus.github.io/posts/audio-from-scratch-pt8/` — Go waveform synthesis from scratch, confirms no library needed
- `github.com/golang/go/issues/76320` — UserConfigDir XDG_CONFIG_HOME discussion (Nov 2025), confirms existing stdlib support on Linux
---
*Stack research for: NetSynth — Go CLI network-traffic-to-audio tool*
*Researched: 2026-03-24*
*Stack research for: NetSynth v1.1 — Custom Sound Mappings milestone*
*Researched: 2026-03-26*
+111 -112
View File
@@ -1,188 +1,187 @@
# Project Research Summary
**Project:** NetSynth
**Domain:** Network traffic sonification CLI — Go, packet capture, audio synthesis, MP3 encoding
**Researched:** 2026-03-24
**Confidence:** MEDIUM-HIGH
**Project:** NetSynth v1.1 — Custom Sound Mappings
**Domain:** Network traffic sonification CLI tool (Go)
**Researched:** 2026-03-26
**Confidence:** HIGH
## Executive Summary
NetSynth is a Go CLI tool that captures live network traffic, classifies packets by protocol, and synthesizes an ambient drone MP3 where each protocol layer produces a distinct tonal frequency whose amplitude evolves with traffic volume. There is no direct precedent for this exact form factor: comparable tools (SoNSTAR, Peep, Network-Sonification) all produce real-time audio through OS audio APIs rather than file output, are not single binaries, and do not auto-cluster unknown traffic. The recommended approach builds on well-understood Go concurrency primitives (channel-connected pipeline stages, ticker-driven time windows) rather than audio or ML libraries — the synthesis math is ~100 lines of Go and no external audio framework adds value for batch file output.
NetSynth v1.1 extends the working v1.0 CLI by adding user-customizable sound mappings through a TOML config file. The v1.0 codebase is a clean 6-package Go project (3,254 lines) with a well-separated pipeline: capture → classify → aggregate → synthesize → encode. The v1.1 milestone threads a new `config` package through this pipeline, enabling users to override frequencies and waveforms per traffic class, add new classification rules, and reference those custom classes in the synth layer. The recommended implementation path is incremental — introduce waveform types at the oscillator level first, then decouple bank construction from the global config, then add the TOML loader and wire it all together. Each step is independently testable before the next begins.
The recommended stack is `gopacket/gopacket` v1.5.0 (community fork, not the abandoned Google repo) for packet decode, `packetcap/go-pcap` for pure-Go live capture on Linux/macOS, `sjzar/go-lame` v0.0.9 for embedded-CGo MP3 encoding, and `spf13/cobra` v1.10.2 for CLI structure. The audio synthesis layer should be hand-rolled — additive sine oscillators with exponential-moving-average amplitude smoothing. This stack requires CGo at build time but produces a self-contained binary with no runtime library dependencies beyond `CAP_NET_RAW` or root for packet capture.
The stack requires one new dependency: `github.com/BurntSushi/toml` v1.6.0 (zero indirect deps, `MetaData.Undecoded()` provides strict-mode typo detection). All other additions — waveform math, config file discovery, and class name validation — are stdlib-only. The single notable technical choice is waveform synthesis strategy: naive direct-math square/sawtooth/triangle waveforms produce audible aliasing at the frequencies NetSynth uses (651047 Hz). The existing additive synthesis infrastructure (`[]HarmonicDef`) is the correct approach, generating bandlimited harmonic series for each waveform type rather than direct time-domain computation.
The two hardest risks are at opposite ends of the pipeline. On the capture side: privilege requirements, CGo binary distribution contracts, and kernel buffer drops all bite in production but not in local development. On the audio side: PCM integer overflow, perceptual tone masking between protocol frequencies, and LAME initialization order all produce silent or subtle corruption that integration tests must specifically cover. Both risk clusters must be resolved in Phase 1 and Phase 2 respectively — they cannot be retrofitted.
The highest-risk integration points are bank construction (which must be extended to handle user-defined classes not in the static `AllClasses()` list) and TOML merge semantics (TOML decoders zero-out absent fields, silently overwriting defaults unless pointer fields are used). Both are well-understood problems with clear prevention patterns that must be established before wiring config into the pipeline. The merge ordering for classification rules also requires deliberate design: user rules must precede specific built-in rules, which must precede catch-alls — three-layer ordering, not two.
## Key Findings
### Recommended Stack
The stack is straightforward for Go developers with one critical trap: `github.com/google/gopacket` is unmaintained (270 open issues, no active merges since 2022) and must never be used — the import path is `github.com/gopacket/gopacket` (community fork, v1.5.0, Go 1.24+ required). For the capture backend, `packetcap/go-pcap` is pure Go and eliminates libpcap CGo entirely; the MP3 encoder `sjzar/go-lame` embeds LAME C source and requires CGo but no system library on the target machine. Audio synthesis should be written directly — no audio library is appropriate for batch file output. See `.planning/research/STACK.md` for full alternatives matrix.
The existing stack (gopacket v1.5.0, packetcap/go-pcap, sjzar/go-lame v0.0.9, cobra v1.10.2, hand-rolled sine oscillator + EMA) is unchanged. The only new external dependency is `BurntSushi/toml` v1.6.0, selected over `pelletier/go-toml v2` because `MetaData.Undecoded()` is more ergonomic for typo detection on a single startup config read, and over `spf13/viper` because Viper pulls in 20+ transitive deps for features (remote config, env var binding, hot reload) that are irrelevant here. See `.planning/research/STACK.md` for full alternatives analysis.
**Core technologies:**
- `github.com/gopacket/gopacket` v1.5.0: packet decode (ICMP, DNS, TCP, UDP, TLS layers) — only maintained Go packet library
- `github.com/packetcap/go-pcap`: live capture backend — pure Go, mmap ring buffer, Linux/macOS, no CGo
- `github.com/sjzar/go-lame` v0.0.9: MP3 encoding — embeds LAME C source, no runtime `.so` dependency, requires CGo at build time
- `github.com/spf13/cobra` v1.10.2: CLI flag parsing — industry standard, handles signal plumbing and help generation
- `github.com/go-audio/wav`: WAV intermediate format — decouples synthesis from encoding, provides debug artifact
- Hand-rolled additive synthesizer: 30-50 lines of Go sine oscillators, no library needed
- `github.com/gopacket/gopacket` v1.5.0 packet decode — only maintained Go packet library, Go 1.24+
- `github.com/packetcap/go-pcap` — pure-Go live capture backend — no CGo, mmap ring buffer, Linux/macOS
- `github.com/sjzar/go-lame` v0.0.9 MP3 encoding — embeds LAME C source, CGO_ENABLED=1, no system library required
- `github.com/spf13/cobra` v1.10.2 CLI structure and flag handling — industry standard
- `github.com/BurntSushi/toml` v1.6.0 — TOML config parsing — zero transitive deps, strict-mode via `Undecoded()`
- Hand-rolled additive oscillator (sine today, square/sawtooth/triangle in v1.1) — no audio library needed
### Expected Features
NetSynth has a well-defined feature set. All precedent tools provide real-time audio output, not file output — this is both a differentiator and a source of user confusion to address in UX copy. Auto-clustering of unknown traffic is unique to NetSynth among comparable tools. See `.planning/research/FEATURES.md` for full prioritization matrix and competitor analysis.
**Must have (table stakes) for v1.1:**
- TOML config auto-discovery (`./netsynth.toml`, `~/.config/netsynth/config.toml`) — XDG Base Directory Specification standard
- `--config` flag for explicit path, with hard error if file is absent
- Partial override semantics — absent keys retain defaults; users must not replicate the full table to change one field
- Custom frequency per traffic class — direct override of `synth.ClassFreqConfigs`
- Custom waveform per traffic class — sine/square/sawtooth/triangle selection
- User-defined classification rules with custom class names, prepended before built-in rules
- Startup-time config validation with line-number errors (fail before capture begins, not after)
- Unknown field detection — prevents silent typos (`frequncy` must be caught, not silently ignored)
**Must have (table stakes):**
- Interface selection (`-i eth0`) and `--list-interfaces` — packet capture CLI convention
- Live capture with graceful Ctrl+C stop producing a valid MP3 — core interaction model
- Per-protocol sound distinction: ICMP, DNS, TCP/443, TCP/other, UDP — minimum fingerprint set
- Ambient drone synthesis with time-windowed amplitude evolution — core differentiator
- MP3 encoding and file output with sensible default filename — deliverable artifact
- Capture statistics summary on exit (stderr) — expected by every capture tool user
- Privilege error detection with actionable message — prevents silent failure
- Minimum viable duration guard — zero-packet capture must not produce a corrupt file
**Should have (competitive):**
- Auto-clustering of unrecognized traffic into stable drone layers — honest representation, unique feature
- BPF capture filter (`--filter`) — power user scope control
- Offline pcap file input (`--read`) — historical analysis and demos
- Verbose protocol activity log (`--verbose`) — debugging and demo use cases
- Configurable time window duration (`--window`) — tuning for responsiveness vs. smoothness
**Should have (differentiators):**
- `netsynth --print-config` subcommand dumping effective config as commented TOML — critical for discoverability
- Named custom rules (display name appears in exit summary and `--verbose` output)
- Clear error message listing valid waveform values on invalid input
**Defer (v2+):**
- Custom sound mapping configuration file — needs user research first; well-chosen defaults cover v1
- Improved clustering (full k-means on flow features) — hash-bucketing is sufficient for v1
- Multi-interface capture — adds deduplication complexity
- Real-time audio playback — anti-feature; triples cross-platform complexity, conflicts with file-output simplicity
- Harmonic override per class (expose `HarmonicDef` slice in TOML) — niche, adds TOML nesting complexity
- Stereo pan position in config — explicitly deferred per project constraints
- Config hot-reload during capture — mid-capture state change corrupts synthesis; not worth the complexity
- Multiple config file includes/inheritance — single file merged with in-code defaults is sufficient
### Architecture Approach
The architecture is a channel-connected pipeline of five stages, each a goroutine communicating via buffered channels, with a `done` channel closed on Ctrl+C driving clean shutdown across all stages. The stages are: Capture (pcap handle → `chan gopacket.Packet`), Classification (rule-based protocol dispatch + unknown traffic bucketer → `chan ClassifiedPacket`), Aggregation (ticker-driven time-window accumulator → `chan WindowSnapshot`), Synthesis (per-class sine oscillators with EMA amplitude smoothing → PCM blocks), and Encoding (LAME encoder goroutine consuming PCM blocks, separate from synthesis to decouple CGo latency). This is the idiomatic Go pipeline pattern and directly follows the build order the architecture research prescribes. See `.planning/research/ARCHITECTURE.md` for full data flow diagrams and channel buffer size recommendations.
v1.1 adds a `config` package and threads it through the existing pipeline via dependency injection. The key architectural shift: `synth.NewBank` currently reads the package-level global `ClassFreqConfigs`; after v1.1 it accepts a `map[TrafficClass]FreqConfig` parameter, enabling user-defined classes and eliminating hidden global state. The `config` package owns TOML parsing, file discovery, default config wrapping, and merge logic. `classify.DefaultRules` splits into `SpecificRules` + `CatchAllRules` so user rules can be inserted between them. The oscillator gains a `Waveform` enum field with additive-synthesis dispatch. All changes are contained to well-bounded components; `classify/classifier.go` and `encode/mp3.go` change only at their call sites. See `.planning/research/ARCHITECTURE.md` for full data flow diagrams and step-by-step build order.
**Major components:**
1. `capture/` — pcap handle wrapper; privilege/interface boundary; all downstream code is pcap-free
2. `classify/` — protocol rule dispatch + feature-hash bucketer for unknown traffic; testable with synthetic packets
3. `aggregate/` — ticker-driven time-window accumulator; the only stateful time-domain component
4. `synth/` — sine oscillators + EMA amplitude smoothing + mixer; pure PCM math, no I/O
5. `encode/` — CGo LAME boundary; decoupled encoder goroutine; if encoder changes, only this package changes
6. `config/` — static frequency-to-protocol mapping table; prevents magic numbers in synth/
1. `config/` (new) — TOML struct definitions, `Load()`, auto-discovery via `os.UserConfigDir()`, pointer-field merge, `DefaultConfig()`
2. `synth/oscillator.go` (modified) — `Waveform` enum, `sampleAt()` dispatch, bandlimited harmonic series generation at config load time
3. `synth/bank.go` (modified) — accepts freq config map param; iterates map keys, not hardcoded `AllClasses()`
4. `classify/rules.go` (modified) — split into `SpecificRules` + `CatchAllRules`; `MergeRules(userRules)` export
5. `cmd/netsynth/main.go` (modified) — `--config` flag, `config.Load()`, user rule merge, config forwarded to `RunSynthesis`
### Critical Pitfalls
Nine pitfalls identified, ranging from critical (causes data corruption or broken binaries) to moderate (causes poor audio quality). The top five require architectural decisions in Phase 1 or Phase 2 — they cannot be patched later without significant rewrite. See `.planning/research/PITFALLS.md` for recovery strategies and the full "Looks Done But Isn't" checklist.
1. **TOML decoder zeros absent fields, silently overwriting defaults (Pitfall A1)** — Use pointer fields (`*float64`, `*string`) for all optional overrides in the decoded struct. Apply an explicit merge function that only writes non-nil values over the built-in defaults. Establish this pattern before any config is wired into the bank.
1. **Wrong gopacket fork (`google/` vs `gopacket/`)** — use `github.com/gopacket/gopacket` from day one; import path migration across the whole codebase is the recovery cost
2. **CGo breaking the single-binary promise** — decide the static vs. dynamic linking strategy before writing capture code; verify with `ldd` on a clean Alpine container in CI
3. **`CAP_NET_RAW` + nosuid filesystem = silent failure** — install to `/usr/local/bin` for capability mode; always provide a `sudo ./netsynth` fallback with clear error messaging
4. **PCM integer overflow producing wrap-around distortion** — synthesize internally in `float64 [-1.0, 1.0]`, clamp before casting to `int16`; never do audio math in integer types
5. **Perceptual tone masking (all protocols in the same frequency band)** — design the frequency table with register separation: low drones for bulk traffic, mid for control, high for interactive; use harmonic intervals not arithmetic spacing
2. **User rules appended after catch-alls are unreachable (Pitfall A5/A9)**`DefaultRules` ends with catch-all rules (`DstPort: 0`) that match any TCP/UDP packet. Appending user rules after them makes user rules unreachable. Split into `SpecificRules` + `CatchAllRules`; merge order must be `userRules + SpecificRules + CatchAllRules`.
3. **User-defined classes have no bank layer — panic or silence (Pitfall A6)**`NewBank` currently iterates `classify.AllClasses()` (a hardcoded list of 14 built-in classes). User classes will not be in that list. `NewBank` must iterate the keys of the merged `FreqConfig` map instead. Validate at config load that every rule's class name resolves to a configured sound entry.
4. **Naive square/sawtooth/triangle waveforms produce audible aliasing (Pitfall A3)** — Direct time-domain math generates infinite harmonics that alias above Nyquist. Use additive synthesis: generate a bandlimited `[]HarmonicDef` series (odd harmonics for square/triangle, all harmonics for sawtooth, truncated at Nyquist) at config load time. The existing `HarmonicDef` infrastructure already supports this approach.
5. **BurntSushi/toml silently ignores unknown keys by default (Pitfall A2)** — Use `toml.Decode()` (not `Unmarshal`) to obtain `MetaData`, then call `md.Undecoded()` and return an error listing any unrecognized keys. Implement strict decoding from the first config load function.
## Implications for Roadmap
Based on research, the architecture's build-order prescription maps directly to a three-phase roadmap. Each phase is independently testable before the next is wired in.
The v1.1 work has clear dependency ordering that directly dictates phase structure. The build order in ARCHITECTURE.md (7 steps, each independently testable) maps naturally to implementation phases.
### Phase 1: Capture and Classification Pipeline
### Phase 1: Waveform Types in the Oscillator
**Rationale:** The packet capture layer carries the highest technical risk (privilege, CGo, binary distribution, buffer overflow). Validating it first — before any audio code exists — means the hardest pitfalls are resolved while the codebase is small. The architecture research explicitly names this as the correct first step.
**Rationale:** Zero external dependencies; pure math testable in isolation with golden-sample unit tests. The critical design decision — additive synthesis vs. direct math — must be made and locked in here. Switching after integration is a full oscillator rewrite.
**Delivers:** `Waveform` enum, `sampleAt()` dispatch, `BandlimitedHarmonics()` generator; all four waveform types produce correct, alias-free output at all NetSynth frequencies.
**Addresses:** Custom waveform per class (table stakes)
**Avoids:** Pitfall A3 (aliasing from naive waveforms)
**Files changed:** `synth/oscillator.go` only
**Delivers:** A working CLI that opens a network interface, classifies packets by protocol, and prints a live traffic summary to stderr. No audio output yet — just proof the pipeline works. The privilege error message and `--list-interfaces` flag ship here.
### Phase 2: Decouple Bank from Global Config
**Addresses:** Interface selection, live capture, protocol classification, privilege detection, `--list-interfaces`, capture statistics
**Rationale:** Prerequisite for config injection. `NewBank` must accept an injected config map before the `config` package exists. Wiring `Waveform` through `FreqConfig``Layer``Oscillator` is included here; zero-value default (`WaveformSine = 0`) means existing tests pass unchanged.
**Delivers:** `NewBank(tau, cfgs map[TrafficClass]FreqConfig)`, `FreqConfig.Waveform` field, `synth/layer.go` updated. System is functionally identical to v1.0 but injectable.
**Avoids:** Global-read anti-pattern (multiple places reading `ClassFreqConfigs`, ambiguous merge point)
**Files changed:** `synth/config.go`, `synth/layer.go`, `synth/bank.go`, `encode/mp3.go`
**Avoids:** Wrong gopacket fork (Pitfall 1), CGo distribution contract (Pitfall 2), CAP_NET_RAW binary location (Pitfall 3), packet buffer overflow (Pitfall 4), ZeroCopy use-after-free (Pitfall 5)
### Phase 3: Config Package — TOML Loading and Merge
### Phase 2: Audio Synthesis Engine
**Rationale:** Core new infrastructure. Builds on the injectable bank signature from Phase 2. All config correctness patterns (pointer fields, strict decode, validation, string normalization) must be established here in isolation before any features are wired to the bank. Retrofitting these patterns after pipeline integration is significantly more expensive.
**Delivers:** `config/` package with `Load()`, `os.UserConfigDir()` discovery, pointer-field merge, `validate()` with string normalization and enum checking, `DefaultConfig()` wrapping existing values.
**Addresses:** Config auto-discovery, `--config` flag, partial override semantics, unknown field detection, startup validation, clear error messages
**Avoids:** Pitfalls A1 (zero-value overwrite), A2 (silent typos), A4 (waveform string case), A7 (XDG ignored), A8 (missing explicit path), A10 (empty class name)
**Rationale:** The synthesis engine is pure PCM math with no pcap dependency. It can be built and tested in isolation with synthetic `WindowSnapshot` inputs before any real traffic flows through it. This is the architecture research's explicit recommendation. Separating synthesis from capture also means audio bugs are diagnosed without needing a live network.
### Phase 4: Classification Rule Merging
**Delivers:** A synthesizer that accepts `WindowSnapshot` inputs and produces a valid MP3 file. End-to-end smoke test: silence input → `ffprobe`-validated MP3 output. The frequency mapping table, EMA amplitude smoothing, and the LAME encoder goroutine all ship here.
**Rationale:** `classify/rules.go` must be split before any config-loading logic references the rule slice. The three-layer merge order is a design decision that, if wrong, produces silent failures with no error messages — it must be validated with unit tests before pipeline integration.
**Delivers:** `classify.SpecificRules`, `classify.CatchAllRules`, `classify.MergeRules(userRules []Rule) []Rule`; user rules prepend correctly with both catch-all and specific-rule ordering.
**Avoids:** Pitfall A5 (unreachable rules after catch-alls), Pitfall A9 (user rule shadowed by built-in specific rule for same port)
**Uses:** `sjzar/go-lame`, `go-audio/wav`, hand-rolled oscillator, `config/mapping.go` frequency table
### Phase 5: Wire Config Through Pipeline — Frequency and Waveform Overrides
**Implements:** `synth/` (oscillator, layer, mixer), `encode/` (LAME goroutine), `aggregate/` (time-window accumulator), `config/` (frequency mapping)
**Rationale:** Connects the config package to the synth layer for built-in classes only. Validates the full pipeline end-to-end before adding the complexity of user-defined classes. `encode.RunSynthesis` signature change is a breaking API change — all call sites must be updated in a single commit.
**Delivers:** `--config` cobra flag, `config.Load()` in `main.go`, merged freq config map passed to `RunSynthesis` and `NewBank`; end-to-end test: TOML sets HTTPS to 200 Hz sawtooth, bank produces 200 Hz sawtooth layer.
**Implements:** Config → synth integration
**Avoids:** LAME initialization errors (Pitfall 6), PCM overflow wrap-around (Pitfall 7), perceptual tone masking (Pitfall 8), time window jitter (Pitfall 9)
### Phase 6: User-Defined Classes End-to-End
### Phase 3: Pipeline Integration and CLI Polish
**Rationale:** The most complex integration; requires all prior phases. User-defined classes create new `TrafficClass` strings that must exist in both the merged rule set and the bank's layer map. The AllClasses() decoupling from Phase 2 makes this tractable.
**Delivers:** `[[rules]]` TOML section, dynamic `TrafficClass` values from config, bank layers constructed from merged FreqConfig map keys, class name cross-validation at config load.
**Avoids:** Pitfall A6 (user-defined class has no bank layer — nil panic or silence)
**Rationale:** Wire the Phase 1 capture/classify pipeline to the Phase 2 synthesis engine via the aggregation layer. Add Ctrl+C shutdown producing a valid MP3 (requires coordinated drain across all goroutines). Add auto-clustering of unknown traffic. Add UX features (progress output, file collision warning, graceful empty-capture error).
### Phase 7: Print-Config and UX Polish
**Delivers:** The complete v1 MVP: live capture → protocol classification + auto-clustering → time-windowed synthesis → MP3 file output. Graceful Ctrl+C with valid MP3. Full UX surface (startup interface announcement, per-window progress line, exit statistics).
**Addresses:** Auto-clustering, graceful Ctrl+C stop, capture statistics, progress feedback, output file collision warning, zero-packet guard, `main.go` pipeline wiring
**Uses:** All Phase 1 and Phase 2 components; `muesli/kmeans` or hash-bucketing for unknown traffic clustering
### Phase 4: Power User Features (v1.x)
**Rationale:** These features add value for specific user segments but have no blocking dependencies on each other — add in any order based on user feedback after the core is validated.
**Delivers:** BPF capture filter (`--filter`), offline pcap file input (`--read`), verbose protocol log (`--verbose`), configurable time window (`--window`), configurable output duration for pcap input (`--duration`)
**Addresses:** All P2 features from the prioritization matrix in FEATURES.md
**Rationale:** `--print-config` is independent of capture and must wait until all config structure is stable (Phase 6). Additive, zero regression risk.
**Delivers:** `netsynth --print-config` subcommand with commented TOML output of effective config; optional `name` field on user rules displayed in exit summary.
### Phase Ordering Rationale
- **Capture before synthesis:** The privilege and CGo pitfalls are foundational — an audio-first approach would hide them until integration and make them expensive to fix.
- **Synthesis in isolation:** Pure PCM math is independently testable. Building it against synthetic inputs before real traffic makes audio bugs fast to diagnose.
- **Integration as its own phase:** The shutdown coordination (Ctrl+C → drain → flush encoder → close file) across five goroutines is non-trivial; it deserves focused attention rather than being an afterthought of feature development.
- **Power features deferred:** BPF filter and offline pcap do not validate the core concept; they add complexity to the capture layer that should wait until the pipeline is stable.
- Phases 12 are internal refactors with no user-visible change — the right starting point for establishing patterns safely
- Phase 3 owns all config safety in one isolated package before anything is wired — retrofitting pointer-field merge after bank integration means touching multiple packages simultaneously
- Phase 4 (rule splitting) must precede Phase 6 (user rules) or catch-all ordering bugs surface silently at integration with no clear failure signal
- Phase 5 validates the full pipeline with familiar built-in classes before Phase 6 introduces the harder user-defined class problem
- Phase 7 is pure additive polish with zero risk of breaking earlier phases
### Research Flags
Phases likely needing deeper research during planning:
Phases with well-documented patterns — skip additional research:
- **Phase 1:** DSP textbook math; harmonic series are fully specified
- **Phase 2:** Standard dependency injection refactor; no unknowns
- **Phase 3:** BurntSushi/toml API is well-documented; pointer-field merge is a known TOML pattern
- **Phase 4:** Simple slice manipulation; no external dependencies
- **Phase 7:** Cobra subcommand and TOML marshal are standard patterns
- **Phase 2 (Audio Synthesis):** The perceptual frequency mapping table requires listening tests, not just code correctness. Research the auditory masking literature before finalizing `config/mapping.go`. Consider consulting the SoNSTAR PLOS One paper on time window choices.
- **Phase 3 (Auto-clustering):** The decision between simple hash-bucketing and k-means clustering (muesli/kmeans) depends on what "meaningfully distinct drone layers" means in practice. This needs a working synthesis engine to evaluate — defer the decision to Phase 3 planning.
- **Phase 4 (Offline pcap):** Time-compression of multi-hour pcap files to a fixed audio duration needs a clear algorithm decision (proportional window scaling vs. fixed window with truncation). Research this when Phase 4 is planned.
Phases with standard patterns (skip research-phase):
- **Phase 1 (Capture pipeline):** Go channel pipelines and gopacket usage are thoroughly documented. The pitfalls are known and avoidable with the guidance in PITFALLS.md.
- **Phase 3 (Pipeline integration):** Go done-channel shutdown patterns are canonical (Go Blog: Pipelines). No novel research needed.
Phases that may benefit from a targeted research pass or design review:
- **Phase 5:** `encode.RunSynthesis` signature change is a breaking API change — verify all test call sites and plan a single-commit update
- **Phase 6:** User-defined class name collision with built-in `TrafficClass` string values (e.g., user names a class `"HTTPS"`) requires a design decision: treat as override of built-in sound vs. reject as ambiguous. Not resolved in research; decide before coding Phase 6.
## Confidence Assessment
| Area | Confidence | Notes |
|------|------------|-------|
| Stack | HIGH | Core libraries confirmed via pkg.go.dev; version numbers verified; `go-audio/generator` archived status confirmed February 2026 |
| Features | MEDIUM | Niche domain with few direct CLI comparators; feature set derived from tcpdump conventions and sonification research, not user surveys |
| Architecture | MEDIUM-HIGH | Go pipeline patterns are HIGH confidence (official Go Blog); audio synthesis architecture inferred from SoNSTAR paper (MEDIUM, abstract-level access only) |
| Pitfalls | HIGH | Packet capture pitfalls verified against official gopacket issues and libpcap docs; audio pitfalls cross-referenced against DSP literature and encoder post-mortems |
| Stack | HIGH | Existing stack validated in v1.0; BurntSushi/toml v1.6.0 confirmed via pkg.go.dev and GitHub releases; `os.UserConfigDir()` XDG behavior confirmed against official Go stdlib docs |
| Features | HIGH | Config file conventions verified against XDG spec, git, golangci-lint, and mise patterns; TOML schema grounded in existing v1.0 codebase types |
| Architecture | HIGH | Based on direct code inspection of the 3,254-line v1.0 codebase; all integration points identified with specific file/line references and build order prescribed |
| Pitfalls | HIGH | TOML default-overwrite behavior verified against upstream issue trackers (BurntSushi/toml #47, go-toml #252); aliasing prevention verified against DSP literature (CCRMA paper, McGill bandlimited synthesis notes) |
**Overall confidence:** MEDIUM-HIGH
**Overall confidence:** HIGH
### Gaps to Address
- **Frequency mapping validation:** The correct frequency assignments for the protocol drone layers require subjective listening tests with real traffic. The research prescribes the *approach* (register separation, harmonic intervals) but not specific Hz values. Validate during Phase 2 with a listening session before Phase 3 integration.
- **Auto-clustering granularity:** How many unknown-traffic clusters are perceptually useful? The research suggests hash-bucketing is acceptable for v1, but does not validate how many distinct cluster tones are distinguishable simultaneously. Validate during Phase 3 with real mixed traffic.
- **muesli/kmeans maintenance status:** Last release July 2022 (LOW confidence on ongoing maintenance). If Go 1.24 compatibility issues emerge, the alternative is `mpraski/clusters` (online clustering) or a hand-rolled hash bucketer. Plan for substitution.
- **macOS privilege model:** CAP_NET_RAW pitfall was verified for Linux. macOS uses a different privilege model (BPF device permissions). If macOS is a target, verify the privilege flow and error messages during Phase 1.
- **User class name collision with built-in class strings:** If a user writes `class = "HTTPS"` in a `[[rules]]` block, the intent could be "override built-in sound" or "create a parallel custom class." The merge logic needs an explicit decision before Phase 6: treat matching names as overrides (simplest) or require a separate TOML section. This is a UX design question — resolve before coding Phase 6.
- **BurntSushi/toml vs. go-toml v2:** Both work for this use case. BurntSushi is recommended for ergonomics, but if the team prefers go-toml v2's `DisallowUnknownFields()` pattern, it is equally valid with minor API differences. Either choice is fine; just make one and be consistent.
## Sources
### Primary (HIGH confidence)
- `github.com/gopacket/gopacket` releases — v1.5.0 November 2025, Go 1.24+ confirmed
- `pkg.go.dev/github.com/packetcap/go-pcap` — pure Go, Linux/macOS confirmed
- `pkg.go.dev/github.com/sjzar/go-lame` — v0.0.9 April 2025, embedded C source confirmed
- `pkg.go.dev/github.com/spf13/cobra` — v1.10.2 December 2025
- Go Blog: Pipelines and cancellation — canonical Go pipeline pattern
- `google/gopacket` issue #1016 — unmaintained status confirmed
- `google/gopacket` issue #329 — 98% packet drop under high traffic, afpacket solution
- linuxvox.com: CAP_NET_RAW + nosuid filesystem behavior
- Direct code inspection: `synth/config.go`, `synth/oscillator.go`, `synth/bank.go`, `synth/layer.go`, `classify/classifier.go`, `classify/rules.go`, `classify/types.go`, `encode/mp3.go`, `cmd/netsynth/main.go`
- `pkg.go.dev/github.com/BurntSushi/toml` — v1.6.0 API, `Undecoded()` strict mode, pointer field behavior
- `github.com/BurntSushi/toml/issues/47` — default-overwrite behavior when using `Unmarshal` confirmed
- `pkg.go.dev/os#UserConfigDir` — XDG_CONFIG_HOME behavior on Linux confirmed via official Go stdlib docs
- `ccrma.stanford.edu/~stilti/papers/blit.pdf` — bandlimited synthesis theory (alias-free waveforms)
- `music.mcgill.ca/~gary/307/week5/bandlimited.html` — truncated harmonic series approach confirmed
### Secondary (MEDIUM confidence)
- SoNSTAR PLOS One paper (arXiv 1712.07029) — time window design and sonification architecture
- braheezy.github.io: Go MP3 encoding options — shine-mp3 not production-grade
- Dylan Meeus: Audio From Scratch With Go — PCM synthesis patterns
- `github.com/go-audio/generator` — archived February 2026, do not use (confirmed read-only)
- XDG Base Directory Specification — config discovery precedence order
- `mise.jdx.dev/configuration.html` — working-dir + XDG config discovery pattern
- `golangci-lint.run/docs/configuration/cli/` — partial override config in Go CLI tools
- `dylanmeeus.github.io/posts/audio-from-scratch-pt8/` — Go waveform synthesis from scratch, confirms no library needed
- `pkg.go.dev/github.com/pelletier/go-toml/v2` — v2.3.0 strict decoder comparison; `pelletier/go-toml/issues/252` partial v2 resolution of default-overwrite
### Tertiary (LOW confidence)
- `muesli/kmeans` v0.3.1 (July 2022) — last release date only; ongoing Go 1.24 compatibility unverified
- Drone auralization model, Acta Acustica 2024 — amplitude/frequency modulation patterns (MEDIUM, used for perceptual guidance)
- Competitor feature table (SoNSTAR, Network-Sonification, Peep) — niche domain, limited documentation; used for context only, not binding decisions
---
*Research completed: 2026-03-24*
*Research completed: 2026-03-26*
*Ready for roadmap: yes*
+264
View File
@@ -0,0 +1,264 @@
# NetSynth
**Turn network traffic into ambient sound.**
NetSynth captures live network traffic (or reads pcap files), classifies packets by protocol, and synthesizes an ambient MP3 soundscape where each traffic type produces a distinct harmonic drone. A ping sounds different from HTTPS noise, which sounds different from a port scan.
Run it, let it listen, press Ctrl+C, get an audio fingerprint of your network.
## Quick Start
```bash
# Live capture on eth0 (requires root or CAP_NET_RAW)
sudo netsynth -i eth0
# Press Ctrl+C after a few seconds → saves netsynth-<timestamp>.mp3
# Sonify a pcap file (no privileges needed)
netsynth --read capture.pcap -o output.mp3
# Filter to DNS traffic only
sudo netsynth -i eth0 --filter "port 53" -o dns.mp3
# Use a custom sound config
sudo netsynth -i eth0 --config my-sounds.toml
# Print effective config as a starting template
netsynth --print-config > my-sounds.toml
```
## Installation
### Prerequisites
- Go 1.24+
- C compiler (GCC or Clang) — required for MP3 encoding (CGo)
### Build from Source
```bash
git clone https://codeberg.org/gurix/yoloyolo.git
cd yoloyolo
go build -o netsynth ./cmd/netsynth
```
For a smaller binary:
```bash
go build -ldflags="-s -w" -o netsynth ./cmd/netsynth
```
### Privileges
Live packet capture requires elevated privileges on Linux:
```bash
# Option A: run as root
sudo ./netsynth -i eth0
# Option B: grant capability (preferred)
sudo setcap cap_net_raw=eip ./netsynth
./netsynth -i eth0
```
## Usage
```
netsynth [flags]
```
| Flag | Description |
|------|-------------|
| `-i`, `--interface` | Network interface to capture on |
| `--read` | Read packets from a pcap file instead of live capture |
| `-o`, `--output` | Output MP3 file path (default: `netsynth-<timestamp>.mp3`) |
| `--filter` | BPF filter expression, tcpdump syntax (e.g. `"port 53"`) |
| `--config` | Path to a TOML config file for custom sound mappings |
| `--print-config` | Print the full effective config as commented TOML and exit |
| `--verbose` | Print per-window protocol activity to stderr |
| `--list-interfaces` | List available network interfaces and exit |
### Examples
```bash
# Capture all traffic, verbose per-window output
sudo netsynth -i wlan0 --verbose
# Only HTTPS traffic
sudo netsynth -i eth0 --filter "tcp port 443" -o https.mp3
# Sonify a Wireshark capture (output derived: capture.pcap -> capture.mp3)
netsynth --read capture.pcap
# Filter a pcap file to UDP only
netsynth --read traffic.pcap --filter "udp" -o udp-only.mp3
# Use a custom sound config
sudo netsynth -i eth0 --config ~/my-sounds.toml
# Print the full effective config (great for creating a template)
netsynth --print-config > my-sounds.toml
```
## Custom Sound Configuration
NetSynth supports TOML config files to override the default sound mappings per traffic class. You can change the frequency and waveform for any class, define your own classification rules, and inspect the effective config — all without affecting defaults you don't touch.
### Config File Discovery
NetSynth looks for config files in this order (first found wins):
1. `--config <path>` flag (error if file does not exist)
2. `./netsynth.toml` in the current working directory
3. `~/.config/netsynth/config.toml`
If no config is found, NetSynth starts silently with built-in defaults.
### Config File Format
```toml
# Override sound settings per traffic class.
# Only the fields you set are changed — everything else keeps its default.
[sounds.ICMP]
frequency = 80.0 # Hz (default: 65.0)
waveform = "triangle" # sine, square, sawtooth, or triangle
[sounds.HTTPS]
frequency = 200.0
[sounds.DNS]
waveform = "square"
```
### Available Traffic Classes
`ICMP`, `DNS`, `HTTPS`, `HTTP`, `SSH`, `SMTP`, `NTP`, `DHCP`, `OtherTCP`, `OtherUDP`, `Unknown1`, `Unknown2`, `Unknown3`, `Unknown4`
### Available Waveforms
| Waveform | Character |
|----------|-----------|
| `sine` | Pure, clean fundamental tone |
| `square` | Hollow, buzzy (odd harmonics) |
| `sawtooth` | Bright, rich (all harmonics) |
| `triangle` | Soft, mellow (odd harmonics, fast rolloff) |
All waveforms use bandlimited additive synthesis to prevent aliasing artifacts.
### Custom Classification Rules
Define your own traffic classification rules using `[[rules]]` blocks. User-defined rules fire before built-in rules (first-match-wins):
```toml
# Match internal API traffic on port 8080
[[rules]]
protocol = "tcp"
port = 8080
class = "InternalAPI"
# Match all UDP traffic (port omitted = match any)
[[rules]]
protocol = "udp"
class = "AllUDP"
# Optionally customize the sound for your custom class
[sounds.InternalAPI]
frequency = 1500.0
waveform = "sawtooth"
```
Custom classes that don't have a `[sounds.*]` entry automatically get a unique frequency in the 1200-2350 Hz range.
### Print Config
Inspect the full effective configuration (defaults merged with your overrides):
```bash
# Print defaults (useful as a starting template)
netsynth --print-config
# Print with your overrides applied
netsynth --config my-sounds.toml --print-config
# Save as a template to edit
netsynth --print-config > template.toml
```
The output includes `(default)`, `(override)`, and `(auto-assigned)` annotations so you can see what's customized.
### Validation
- **Unknown keys** are rejected at startup with an error naming the bad key (catches typos like `frequncy`)
- **Unknown class names** produce a warning but do not prevent startup
- **Invalid waveform values** are rejected with a clear error
## How It Works
NetSynth processes traffic through a four-stage pipeline:
```
Capture -> Classify -> Aggregate -> Synthesize -> MP3
```
1. **Capture** — Packets are read from a live interface (via [go-pcap](https://github.com/packetcap/go-pcap)) or a pcap file. Optional BPF filtering reduces the stream to traffic of interest.
2. **Classify** — Each packet is matched against protocol rules (ICMP, DNS, HTTPS, SSH, HTTP, SMTP, NTP, DHCP, etc.) plus any user-defined rules from the config file. User rules fire first. Unrecognized traffic is deterministically hash-bucketed into 4 "unknown" classes so it still produces distinct sounds.
3. **Aggregate** — Classified packets are grouped into 500ms time windows. Each window records per-protocol packet counts that drive synthesis amplitudes.
4. **Synthesize & Encode** — Each traffic class maps to an oscillator at a specific frequency, waveform, and stereo position. Amplitudes rise and fall via EMA smoothing based on traffic volume. All layers are mixed and encoded to MP3 via [LAME](https://github.com/sjzar/go-lame).
### Sound Design
| Traffic Class | Frequency | Character |
|--------------|-----------|-----------|
| ICMP (Ping) | 65 Hz | Deep, distinctive ping tone |
| DNS | 110 Hz | Quick lookup sound |
| HTTPS/TLS | 175 Hz | Steady drone (bulk traffic) |
| HTTP | 220 Hz | Warm web traffic hum |
| SSH | 330 Hz | Distinct interactive tone |
| SMTP | 440 Hz | Mail delivery tone |
| NTP | 520 Hz | Time sync pulse |
| DHCP | 600 Hz | Network setup sound |
| Other TCP | 700 Hz | Generic TCP hum |
| Other UDP | 780 Hz | Generic UDP hum |
| Unknown 1-4 | 8621047 Hz | Dissonant, attention-grabbing |
Sustained traffic sounds louder; quiet periods fade to silence. The result is a unique audio fingerprint of your network activity. All frequencies and waveforms can be overridden via the [config file](#custom-sound-configuration).
## Project Structure
```
cmd/netsynth/ CLI entry point (Cobra)
capture/ Packet capture, BPF validation, pcap file reading
classify/ Protocol classification rules and types
aggregate/ Time-window aggregation and summary output
synth/ Oscillators, waveforms, EMA layers, stereo mixer, tone bank
config/ TOML config loading, validation, and partial merge
encode/ MP3 encoding via embedded LAME
```
## Dependencies
| Library | Purpose |
|---------|---------|
| [gopacket/gopacket](https://github.com/gopacket/gopacket) | Packet decoding |
| [packetcap/go-pcap](https://github.com/packetcap/go-pcap) | Pure Go live capture (no libpcap) |
| [sjzar/go-lame](https://github.com/sjzar/go-lame) | MP3 encoding (embedded LAME C source) |
| [spf13/cobra](https://github.com/spf13/cobra) | CLI framework |
| [BurntSushi/toml](https://github.com/BurntSushi/toml) | TOML config parsing |
No runtime dependencies beyond the compiled binary. CGo is required at build time only (for LAME).
## Testing
```bash
go test ./... -v
```
All tests run without root privileges (capture tests use mock data and programmatically generated pcap files).
## License
See [LICENSE](LICENSE) for details.
+39 -8
View File
@@ -16,6 +16,7 @@ import (
"github.com/netsynth/netsynth/aggregate"
"github.com/netsynth/netsynth/capture"
"github.com/netsynth/netsynth/classify"
"github.com/netsynth/netsynth/config"
"github.com/netsynth/netsynth/encode"
)
@@ -26,6 +27,8 @@ var (
outputPath string
bpfFilter string // NEW: --filter flag (CAPT-05)
readPath string // NEW: --read flag (CAPT-06)
configPath string // NEW: --config flag (CFG-03)
printConfig bool // NEW: --print-config flag (CFG-06)
)
func main() {
@@ -42,6 +45,8 @@ func main() {
rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output MP3 file path (default: netsynth-<timestamp>.mp3)")
rootCmd.Flags().StringVar(&bpfFilter, "filter", "", "BPF filter expression (tcpdump syntax, e.g. \"port 53\")")
rootCmd.Flags().StringVar(&readPath, "read", "", "Read packets from pcap file instead of live capture")
rootCmd.Flags().StringVar(&configPath, "config", "", "Path to TOML config file (default: auto-discover)")
rootCmd.Flags().BoolVar(&printConfig, "print-config", false, "Print effective config as commented TOML and exit")
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
@@ -64,6 +69,11 @@ func run(cmd *cobra.Command, args []string) error {
return runListInterfaces()
}
// --print-config mode (CFG-06, D-09/D-10): must come before interface-required check
if printConfig {
return runPrintConfig()
}
// D-03: --read and -i are mutually exclusive
if readPath != "" && ifaceName != "" {
return fmt.Errorf("--read and -i are mutually exclusive; use one or the other")
@@ -79,6 +89,12 @@ func run(cmd *cobra.Command, args []string) error {
}
}
// Load config (CFG-01 through CFG-05, D-11: fail fast before capture)
result, err := config.Load(configPath)
if err != nil {
return err
}
// Resolve output path
if outputPath == "" {
if readPath != "" {
@@ -89,13 +105,24 @@ func run(cmd *cobra.Command, args []string) error {
}
if readPath != "" {
return runPcapMode(cmd)
return runPcapMode(cmd, result)
}
return runLiveMode(cmd)
return runLiveMode(cmd, result)
}
// runPrintConfig loads the config and prints the effective configuration as commented TOML.
func runPrintConfig() error {
result, err := config.Load(configPath)
if err != nil {
return err
}
output := config.PrintConfig(result)
fmt.Print(output)
return nil
}
// runLiveMode runs the live packet capture pipeline.
func runLiveMode(cmd *cobra.Command) error {
func runLiveMode(cmd *cobra.Command, result config.LoadResult) error {
// Set up signal handling (Ctrl+C)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -108,7 +135,9 @@ func runLiveMode(cmd *cobra.Command) error {
}
// Stage 2: Classify (CLAS-01)
classifier := classify.NewClassifier(classify.DefaultRules)
// D-04: user rules prepend before built-ins; first-match-wins (RULE-02)
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
classified := make(chan classify.ClassifiedPacket, 1024)
go func() {
defer close(classified)
@@ -144,7 +173,7 @@ func runLiveMode(cmd *cobra.Command) error {
// 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 {
if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil {
return fmt.Errorf("synthesis failed: %w", err)
}
encodeElapsed := time.Since(encodeStart)
@@ -161,7 +190,7 @@ func runLiveMode(cmd *cobra.Command) error {
}
// runPcapMode runs the pcap file processing pipeline (CAPT-06).
func runPcapMode(cmd *cobra.Command) error {
func runPcapMode(cmd *cobra.Command, result config.LoadResult) error {
// D-06: bookend start message
fmt.Fprintf(os.Stderr, "Reading %s...\n", readPath)
@@ -172,7 +201,9 @@ func runPcapMode(cmd *cobra.Command) error {
}
// Classify packets (reuse same classifier)
classifier := classify.NewClassifier(classify.DefaultRules)
// D-04: user rules prepend before built-ins; first-match-wins (RULE-02)
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
classified := make(chan classify.ClassifiedPacket, 1024)
go func() {
defer close(classified)
@@ -212,7 +243,7 @@ func runPcapMode(cmd *cobra.Command) error {
// Encode
fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath)
encodeStart := time.Now()
if err := encode.RunSynthesis(collectedSnapshots, outputPath); err != nil {
if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil {
return fmt.Errorf("synthesis failed: %w", err)
}
encodeElapsed := time.Since(encodeStart)
+77
View File
@@ -2,6 +2,8 @@ package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
@@ -16,6 +18,8 @@ func newTestCmd() *cobra.Command {
var testFilter string
var testRead string
var testOutput string
var testPrintConfig bool
var testConfigPath string
rootCmd := &cobra.Command{
Use: "netsynth",
@@ -29,6 +33,8 @@ func newTestCmd() *cobra.Command {
rootCmd.Flags().StringVarP(&testOutput, "output", "o", "", "Output MP3 file path")
rootCmd.Flags().StringVar(&testFilter, "filter", "", "BPF filter expression")
rootCmd.Flags().StringVar(&testRead, "read", "", "Read packets from pcap file instead of live capture")
rootCmd.Flags().BoolVar(&testPrintConfig, "print-config", false, "Print effective config")
rootCmd.Flags().StringVar(&testConfigPath, "config", "", "Path to TOML config file")
// Wire test variables to package-level vars used by run()
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
@@ -38,6 +44,8 @@ func newTestCmd() *cobra.Command {
outputPath = testOutput
bpfFilter = testFilter
readPath = testRead
printConfig = testPrintConfig
configPath = testConfigPath
return nil
}
@@ -320,3 +328,72 @@ func TestHelpOutputNewFlags(t *testing.T) {
t.Errorf("expected help/usage output to contain '--read', usage: %s", usageStr)
}
}
// TestPrintConfigFlagRegistered verifies --print-config flag is registered.
func TestPrintConfigFlagRegistered(t *testing.T) {
rootCmd := newTestCmd()
f := rootCmd.Flags().Lookup("print-config")
if f == nil {
t.Fatal("expected --print-config flag to be registered")
}
}
// TestPrintConfigNoInterface verifies --print-config works without -i flag.
func TestPrintConfigNoInterface(t *testing.T) {
// Reset globals
ifaceName = ""
listIfaces = false
verbose = false
bpfFilter = ""
readPath = ""
outputPath = ""
printConfig = false
configPath = ""
// Use a temp dir with no netsynth.toml so no config is auto-discovered
t.Chdir(t.TempDir())
rootCmd := newTestCmd()
rootCmd.SetArgs([]string{"--print-config"})
var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("--print-config should not require -i, got error: %v", err)
}
}
// TestPrintConfigWithConfigFile verifies --print-config loads and displays a user config.
func TestPrintConfigWithConfigFile(t *testing.T) {
// Create temp TOML with an override
dir := t.TempDir()
tomlPath := filepath.Join(dir, "test.toml")
if err := os.WriteFile(tomlPath, []byte("[sounds.ICMP]\nfrequency = 100.0\n"), 0644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Reset globals
ifaceName = ""
listIfaces = false
verbose = false
bpfFilter = ""
readPath = ""
outputPath = ""
printConfig = false
configPath = ""
rootCmd := newTestCmd()
rootCmd.SetArgs([]string{"--print-config", "--config", tomlPath})
var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("--print-config with --config should succeed, got: %v", err)
}
}
+395
View File
@@ -0,0 +1,395 @@
// Package config loads, validates, and merges a TOML override file over the
// default synth.ClassFreqConfigs map. The single public entry point is Load.
package config
import (
"errors"
"fmt"
"hash/fnv"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/netsynth/netsynth/classify"
"github.com/netsynth/netsynth/synth"
)
// SoundOverride holds optional per-class sound parameters decoded from TOML.
// Pointer fields: nil = not set by user (keep default), non-nil = user override.
type SoundOverride struct {
Frequency *float64 `toml:"frequency"`
Waveform *string `toml:"waveform"`
}
// RawRule holds a user-defined classification rule as decoded from TOML.
// Port is a pointer so we can distinguish "not set" (nil, matches any port) from port=0.
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
// rawConfig is the top-level TOML decode target.
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
// LoadResult is the return type from Load(). It carries the merged FreqConfig map,
// the user-defined classification rules (to be prepended before DefaultRules by the caller),
// the resolved config file path (empty string if no config was found),
// and the set of classes that were auto-assigned frequencies.
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies
}
// validWaveforms maps TOML waveform strings to WaveformType constants.
var validWaveforms = map[string]synth.WaveformType{
"sine": synth.WaveformSine,
"square": synth.WaveformSquare,
"sawtooth": synth.WaveformSawtooth,
"triangle": synth.WaveformTriangle,
}
// Load finds, parses, validates, and merges a TOML config file.
//
// configPath is the --config flag value; empty string triggers auto-discovery.
// Returns a LoadResult with the merged FreqConfig map, user-defined rules, and resolved path.
// Returns an error on: explicit file not found, parse errors, unknown keys,
// type mismatches, invalid waveform values, or invalid rule definitions.
// Returns no error (uses defaults) when no config is found during auto-discovery.
func Load(configPath string) (LoadResult, error) {
path, explicit, err := resolvePath(configPath)
if err != nil {
return LoadResult{}, err
}
if path == "" {
// No config found during auto-discovery — use defaults silently (CFG-02)
return LoadResult{
FreqCfgs: copyDefaults(),
UserRules: []classify.Rule{},
ConfigPath: "",
AutoClasses: map[classify.TrafficClass]bool{},
}, nil
}
raw, err := parseFile(path)
if err != nil {
if explicit && errors.Is(err, fs.ErrNotExist) {
return LoadResult{}, fmt.Errorf("config file not found: %s", configPath)
}
return LoadResult{}, err
}
if err := validate(raw); err != nil {
return LoadResult{}, err
}
userRules := convertRules(raw.Rules)
freqCfgs := copyDefaults()
autoClasses := map[classify.TrafficClass]bool{}
// Add auto-freq entries BEFORE merge so that [sounds.X] overrides for user classes apply.
addAutoFreqEntries(freqCfgs, userRules, autoClasses)
merge(freqCfgs, raw.Sounds)
return LoadResult{
FreqCfgs: freqCfgs,
UserRules: userRules,
ConfigPath: path,
AutoClasses: autoClasses,
}, nil
}
// resolvePath resolves the config path from an explicit flag value or auto-discovery.
// Returns (path, explicit, error) where explicit=true means the user specified a path.
func resolvePath(configPath string) (string, bool, error) {
if configPath != "" {
return configPath, true, nil
}
return discoverPath(), false, nil
}
// discoverPath probes the standard discovery locations in precedence order.
// Returns the first existing config path, or "" if none found.
// Discovery order: ./netsynth.toml > ~/.config/netsynth/config.toml
func discoverPath() string {
// 1. Working directory
if _, err := os.Stat("netsynth.toml"); err == nil {
return "netsynth.toml"
}
// 2. XDG config dir (~/.config/netsynth/config.toml or $XDG_CONFIG_HOME/netsynth/config.toml)
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
p := filepath.Join(dir, "netsynth", "config.toml")
if _, err := os.Stat(p); err == nil {
return p
}
return ""
}
// parseFile decodes the TOML file at path and checks for unknown keys via Undecoded().
// Returns fs.ErrNotExist-wrapped error when the file does not exist.
func parseFile(path string) (rawConfig, error) {
var raw rawConfig
md, err := toml.DecodeFile(path, &raw)
if err != nil {
// Preserve the fs.ErrNotExist sentinel so Load can distinguish explicit vs discovered.
if errors.Is(err, fs.ErrNotExist) {
return raw, err
}
return raw, fmt.Errorf("config parse error: %w", err)
}
// Detect field-level typos within [sounds.<class>] blocks (CFG-05).
// Note: class-name typos in [sounds.<name>] are NOT caught here because all
// map keys are valid decode targets. Class validation happens in merge (D-09).
if undecoded := md.Undecoded(); len(undecoded) > 0 {
keyPath := strings.Join(undecoded[0], ".")
return raw, fmt.Errorf("config: unknown key %q — check spelling", keyPath)
}
return raw, nil
}
// validate checks waveform strings and rules before merge so we fail fast at startup (D-11).
func validate(raw rawConfig) error {
for _, override := range raw.Sounds {
if override.Waveform != nil {
if _, err := parseWaveform(*override.Waveform); err != nil {
return err
}
}
}
return validateRules(raw.Rules)
}
// validateRules checks that each rule has a valid protocol and a non-empty class.
func validateRules(rules []RawRule) error {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
for i, r := range rules {
if r.Protocol == "" {
return fmt.Errorf("config: rules[%d]: protocol is required", i)
}
if !validProtocols[r.Protocol] {
return fmt.Errorf("config: rules[%d]: invalid protocol %q -- valid: tcp, udp, icmp", i, r.Protocol)
}
if r.Class == "" {
return fmt.Errorf("config: rules[%d]: class is required", i)
}
}
return nil
}
// convertRules converts a slice of RawRule (from TOML) into classify.Rule slice.
func convertRules(raw []RawRule) []classify.Rule {
result := make([]classify.Rule, len(raw))
for i, r := range raw {
var port uint16
if r.Port != nil {
port = *r.Port
}
result[i] = classify.Rule{
Protocol: r.Protocol,
DstPort: port,
Class: classify.TrafficClass(r.Class),
}
}
return result
}
// autoAssignFreq computes a deterministic frequency in [1200, 2350] Hz for a class name
// using FNV-32a hashing. Same input always produces the same output.
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = uint32(24)
)
return baseHz + float64(h.Sum32()%numSteps)*stepHz
}
// addAutoFreqEntries adds a FreqConfig entry for each user-defined class that doesn't
// already have one in the map. Built-in classes that appear in user rules are skipped.
// Must be called AFTER merge() so that [sounds.X] overrides are already applied.
// autoClasses is populated with the class names that were auto-assigned.
func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule, autoClasses map[classify.TrafficClass]bool) {
for _, rule := range userRules {
if _, exists := cfgs[rule.Class]; !exists {
baseHz := autoAssignFreq(string(rule.Class))
cfgs[rule.Class] = synth.FreqConfig{
BaseHz: baseHz,
WaveformType: synth.WaveformSine,
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0,
}
autoClasses[rule.Class] = true
}
}
}
// parseWaveform converts a TOML waveform string to a WaveformType.
func parseWaveform(s string) (synth.WaveformType, error) {
if wt, ok := validWaveforms[s]; ok {
return wt, nil
}
valid := []string{"sine", "square", "sawtooth", "triangle"}
return 0, fmt.Errorf("config: invalid waveform %q — valid values: %s", s, strings.Join(valid, ", "))
}
// waveformString converts a WaveformType back to its TOML string representation.
func waveformString(wt synth.WaveformType) string {
switch wt {
case synth.WaveformSine:
return "sine"
case synth.WaveformSquare:
return "square"
case synth.WaveformSawtooth:
return "sawtooth"
case synth.WaveformTriangle:
return "triangle"
default:
return "custom"
}
}
// PrintConfig returns the effective configuration as commented TOML output.
// The output includes a header with source path and generation date, an optional
// [[rules]] section for user-defined rules, and a [sounds.*] section for all
// traffic classes in deterministic order (built-ins first, then user-defined sorted).
// Each sound entry is annotated as (default), (override), or (auto-assigned).
func PrintConfig(result LoadResult) string {
var sb strings.Builder
// Header
fmt.Fprintf(&sb, "# NetSynth effective configuration\n")
if result.ConfigPath != "" {
fmt.Fprintf(&sb, "# Config source: %s\n", result.ConfigPath)
} else {
fmt.Fprintf(&sb, "# Config source: none (using defaults)\n")
}
fmt.Fprintf(&sb, "# Generated: %s\n", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
fmt.Fprintf(&sb, "\n")
// [[rules]] section (if any user rules exist)
if len(result.UserRules) > 0 {
fmt.Fprintf(&sb, "# Classification rules (user-defined, prepended before built-in rules)\n")
for _, rule := range result.UserRules {
fmt.Fprintf(&sb, "[[rules]]\n")
if rule.DstPort != 0 {
fmt.Fprintf(&sb, "port = %d\n", rule.DstPort)
}
fmt.Fprintf(&sb, "protocol = %q\n", rule.Protocol)
fmt.Fprintf(&sb, "class = %q\n", string(rule.Class))
fmt.Fprintf(&sb, "\n")
}
}
// [sounds.*] section — built-in classes first, then user-defined sorted alphabetically
builtinSet := map[classify.TrafficClass]bool{}
for _, cls := range classify.AllClasses() {
builtinSet[cls] = true
}
// Collect user-defined classes (in FreqCfgs but not in AllClasses)
var userClasses []string
for cls := range result.FreqCfgs {
if !builtinSet[cls] {
userClasses = append(userClasses, string(cls))
}
}
sort.Strings(userClasses)
// Emit built-in classes first
for _, cls := range classify.AllClasses() {
cfg := result.FreqCfgs[cls]
annotation := classAnnotation(cls, cfg, result.AutoClasses)
fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", string(cls), cfg.BaseHz, annotation)
fmt.Fprintf(&sb, "[sounds.%s]\n", string(cls))
fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
fmt.Fprintf(&sb, "\n")
}
// Emit user-defined classes sorted alphabetically
for _, clsStr := range userClasses {
cls := classify.TrafficClass(clsStr)
cfg := result.FreqCfgs[cls]
annotation := classAnnotation(cls, cfg, result.AutoClasses)
fmt.Fprintf(&sb, "# %s -- %.1f Hz (%s)\n", clsStr, cfg.BaseHz, annotation)
fmt.Fprintf(&sb, "[sounds.%s]\n", clsStr)
fmt.Fprintf(&sb, "frequency = %.1f\n", cfg.BaseHz)
fmt.Fprintf(&sb, "waveform = %q\n", waveformString(cfg.WaveformType))
fmt.Fprintf(&sb, "\n")
}
return sb.String()
}
// classAnnotation returns the annotation string for a traffic class entry.
// Returns "default", "override", or "auto-assigned".
func classAnnotation(cls classify.TrafficClass, cfg synth.FreqConfig, autoClasses map[classify.TrafficClass]bool) string {
if autoClasses[cls] {
return "auto-assigned"
}
defaultCfg, isBuiltin := synth.ClassFreqConfigs[cls]
if !isBuiltin {
// User-defined class that was manually specified in [sounds.*] (not auto-assigned)
return "override"
}
if cfg.BaseHz == defaultCfg.BaseHz && cfg.WaveformType == defaultCfg.WaveformType {
return "default"
}
return "override"
}
// copyDefaults returns a shallow copy of synth.ClassFreqConfigs.
// Shallow copy is safe because merge assigns fresh Harmonics slices from
// WaveformPresetHarmonics, never mutating the original default slice.
func copyDefaults() map[classify.TrafficClass]synth.FreqConfig {
result := make(map[classify.TrafficClass]synth.FreqConfig, len(synth.ClassFreqConfigs))
for k, v := range synth.ClassFreqConfigs {
result[k] = v
}
return result
}
// merge overlays per-class overrides onto the defaults map in-place.
// Only non-nil pointer fields in each SoundOverride are applied.
func merge(
defaults map[classify.TrafficClass]synth.FreqConfig,
overrides map[string]SoundOverride,
) map[classify.TrafficClass]synth.FreqConfig {
for className, override := range overrides {
class := classify.TrafficClass(className)
cfg, known := defaults[class]
if !known {
// D-09: unknown class name = warning (not error), in case Phase 7 defines it
fmt.Fprintf(os.Stderr, "Warning: config: unknown class %q (ignored)\n", className)
continue
}
if override.Frequency != nil {
cfg.BaseHz = *override.Frequency
// Regenerate harmonics when a waveform preset is active (Pitfall 3)
if cfg.WaveformType != synth.WaveformCustom {
cfg.Harmonics = synth.WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, synth.SampleRate)
}
}
if override.Waveform != nil {
wt, _ := parseWaveform(*override.Waveform) // already validated above
cfg.WaveformType = wt
cfg.Harmonics = synth.WaveformPresetHarmonics(wt, cfg.BaseHz, synth.SampleRate)
}
defaults[class] = cfg
}
return defaults
}
+623
View File
@@ -0,0 +1,623 @@
package config_test
import (
"os"
"strings"
"testing"
"github.com/netsynth/netsynth/classify"
"github.com/netsynth/netsynth/config"
"github.com/netsynth/netsynth/synth"
)
// writeTOML creates a temp TOML file with the given content and returns its path.
func writeTOML(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "*.toml")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatalf("WriteString: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
return f.Name()
}
// TestLoadPartialOverrideFrequency: setting only frequency for ICMP overrides BaseHz,
// leaves WaveformType unchanged (WaveformCustom), and leaves other classes unchanged.
func TestLoadPartialOverrideFrequency(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cfgs := result.FreqCfgs
if cfgs[classify.ClassICMP].BaseHz != 100.0 {
t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz)
}
if cfgs[classify.ClassICMP].WaveformType != synth.WaveformCustom {
t.Errorf("ICMP WaveformType: got %v, want WaveformCustom (0)", cfgs[classify.ClassICMP].WaveformType)
}
// DNS should be unchanged
want := synth.ClassFreqConfigs[classify.ClassDNS].BaseHz
if cfgs[classify.ClassDNS].BaseHz != want {
t.Errorf("DNS BaseHz: got %v, want %v (default)", cfgs[classify.ClassDNS].BaseHz, want)
}
}
// TestLoadPartialOverrideWaveform: setting only waveform for ICMP changes WaveformType,
// leaves BaseHz unchanged, and regenerates Harmonics.
func TestLoadPartialOverrideWaveform(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nwaveform = \"square\"\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cfgs := result.FreqCfgs
if cfgs[classify.ClassICMP].WaveformType != synth.WaveformSquare {
t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType)
}
// BaseHz should be unchanged (default is 65.0)
if cfgs[classify.ClassICMP].BaseHz != 65.0 {
t.Errorf("ICMP BaseHz: got %v, want 65.0 (default)", cfgs[classify.ClassICMP].BaseHz)
}
// Harmonics should be regenerated (non-empty)
if len(cfgs[classify.ClassICMP].Harmonics) == 0 {
t.Error("ICMP Harmonics: got empty slice, expected regenerated harmonics for WaveformSquare")
}
}
// TestLoadBothOverrides: setting both frequency and waveform applies both.
func TestLoadBothOverrides(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\nwaveform = \"square\"\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cfgs := result.FreqCfgs
if cfgs[classify.ClassICMP].BaseHz != 100.0 {
t.Errorf("ICMP BaseHz: got %v, want 100.0", cfgs[classify.ClassICMP].BaseHz)
}
if cfgs[classify.ClassICMP].WaveformType != synth.WaveformSquare {
t.Errorf("ICMP WaveformType: got %v, want WaveformSquare", cfgs[classify.ClassICMP].WaveformType)
}
}
// TestLoadUnknownKey: a typo'd field name produces an error naming the bad key.
func TestLoadUnknownKey(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nfrequncy = 440\n")
_, err := config.Load(path)
if err == nil {
t.Fatal("expected error for unknown key 'frequncy', got nil")
}
if !strings.Contains(err.Error(), "frequncy") {
t.Errorf("error should name the bad key 'frequncy', got: %v", err)
}
}
// TestLoadNoConfig: Load("") in a directory with no netsynth.toml returns defaults with no error.
func TestLoadNoConfig(t *testing.T) {
// Chdir to a temp dir that has no netsynth.toml
t.Chdir(t.TempDir())
result, err := config.Load("")
if err != nil {
t.Fatalf("Load with no config: %v", err)
}
cfgs := result.FreqCfgs
if len(cfgs) != 14 {
t.Errorf("result map size: got %d, want 14", len(cfgs))
}
// ICMP should be at its default BaseHz (65.0)
if cfgs[classify.ClassICMP].BaseHz != 65.0 {
t.Errorf("ICMP BaseHz: got %v, want 65.0 (default)", cfgs[classify.ClassICMP].BaseHz)
}
}
// TestLoadExplicitMissing: an explicit path that doesn't exist returns an error containing "not found".
func TestLoadExplicitMissing(t *testing.T) {
_, err := config.Load("/nonexistent/path/config.toml")
if err == nil {
t.Fatal("expected error for missing explicit file, got nil")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error should contain 'not found', got: %v", err)
}
}
// TestLoadUnknownClass: unknown class name produces no error (warning only), result has 14 entries.
func TestLoadUnknownClass(t *testing.T) {
path := writeTOML(t, "[sounds.BOGUS]\nfrequency = 100.0\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load with unknown class: %v", err)
}
cfgs := result.FreqCfgs
if len(cfgs) != 14 {
t.Errorf("result map size: got %d, want 14 (BOGUS should not appear)", len(cfgs))
}
// Confirm BOGUS is NOT in the map
if _, ok := cfgs["BOGUS"]; ok {
t.Error("BOGUS class should not be present in result map")
}
}
// TestLoadInvalidWaveform: an invalid waveform string produces an error containing "invalid waveform".
func TestLoadInvalidWaveform(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nwaveform = \"invalid\"\n")
_, err := config.Load(path)
if err == nil {
t.Fatal("expected error for invalid waveform, got nil")
}
if !strings.Contains(err.Error(), "invalid waveform") {
t.Errorf("error should contain 'invalid waveform', got: %v", err)
}
}
// TestLoadAllDefaultsPresent: regardless of overrides, all 14 default classes are in the result map.
func TestLoadAllDefaultsPresent(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nfrequency = 200.0\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cfgs := result.FreqCfgs
if len(cfgs) != 14 {
t.Errorf("result map size: got %d, want 14", len(cfgs))
}
for _, class := range classify.AllClasses() {
if _, ok := cfgs[class]; !ok {
t.Errorf("class %q missing from result map", class)
}
}
}
// --- New tests for Phase 7 Plan 01 ---
// TestLoadCustomRules: TOML with [[rules]] block (port=8080, protocol="tcp", class="MyApp")
// plus [sounds.MyApp] (frequency=300.0) parses successfully.
func TestLoadCustomRules(t *testing.T) {
toml := `
[[rules]]
port = 8080
protocol = "tcp"
class = "MyApp"
[sounds.MyApp]
frequency = 300.0
`
path := writeTOML(t, toml)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(result.UserRules) != 1 {
t.Fatalf("UserRules len: got %d, want 1", len(result.UserRules))
}
rule := result.UserRules[0]
if rule.Protocol != "tcp" {
t.Errorf("UserRules[0].Protocol: got %q, want %q", rule.Protocol, "tcp")
}
if rule.DstPort != 8080 {
t.Errorf("UserRules[0].DstPort: got %d, want 8080", rule.DstPort)
}
if rule.Class != "MyApp" {
t.Errorf("UserRules[0].Class: got %q, want %q", rule.Class, "MyApp")
}
if result.FreqCfgs["MyApp"].BaseHz != 300.0 {
t.Errorf("FreqCfgs[MyApp].BaseHz: got %v, want 300.0", result.FreqCfgs["MyApp"].BaseHz)
}
}
// TestLoadCustomRuleNoPort: TOML with [[rules]] (protocol="udp", class="AllUDP", no port field)
// parses; UserRules[0].DstPort == 0.
func TestLoadCustomRuleNoPort(t *testing.T) {
toml := `
[[rules]]
protocol = "udp"
class = "AllUDP"
`
path := writeTOML(t, toml)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(result.UserRules) != 1 {
t.Fatalf("UserRules len: got %d, want 1", len(result.UserRules))
}
if result.UserRules[0].DstPort != 0 {
t.Errorf("UserRules[0].DstPort: got %d, want 0", result.UserRules[0].DstPort)
}
}
// TestLoadCustomRuleMissingProtocol: [[rules]] with class="X" but no protocol -> error containing "protocol is required".
func TestLoadCustomRuleMissingProtocol(t *testing.T) {
toml := `
[[rules]]
class = "X"
`
path := writeTOML(t, toml)
_, err := config.Load(path)
if err == nil {
t.Fatal("expected error for missing protocol, got nil")
}
if !strings.Contains(err.Error(), "protocol is required") {
t.Errorf("error should contain 'protocol is required', got: %v", err)
}
}
// TestLoadCustomRuleMissingClass: [[rules]] with protocol="tcp" but no class -> error containing "class is required".
func TestLoadCustomRuleMissingClass(t *testing.T) {
toml := `
[[rules]]
protocol = "tcp"
`
path := writeTOML(t, toml)
_, err := config.Load(path)
if err == nil {
t.Fatal("expected error for missing class, got nil")
}
if !strings.Contains(err.Error(), "class is required") {
t.Errorf("error should contain 'class is required', got: %v", err)
}
}
// TestLoadCustomRuleInvalidProtocol: [[rules]] with protocol="ftp" -> error containing "invalid protocol".
func TestLoadCustomRuleInvalidProtocol(t *testing.T) {
toml := `
[[rules]]
protocol = "ftp"
class = "FTPTraffic"
`
path := writeTOML(t, toml)
_, err := config.Load(path)
if err == nil {
t.Fatal("expected error for invalid protocol 'ftp', got nil")
}
if !strings.Contains(err.Error(), "invalid protocol") {
t.Errorf("error should contain 'invalid protocol', got: %v", err)
}
}
// TestLoadCustomRuleUnknownField: [[rules]] with typo_field="bad" -> error containing "typo_field".
func TestLoadCustomRuleUnknownField(t *testing.T) {
toml := `
[[rules]]
protocol = "tcp"
class = "SomeClass"
typo_field = "bad"
`
path := writeTOML(t, toml)
_, err := config.Load(path)
if err == nil {
t.Fatal("expected error for unknown field 'typo_field', got nil")
}
if !strings.Contains(err.Error(), "typo_field") {
t.Errorf("error should contain 'typo_field', got: %v", err)
}
}
// TestUserRulesPrepend: Load returns UserRules separately from FreqCfgs;
// caller can do append(result.UserRules, classify.DefaultRules...) to get user rules first.
func TestUserRulesPrepend(t *testing.T) {
toml := `
[[rules]]
protocol = "tcp"
port = 9000
class = "MyService"
`
path := writeTOML(t, toml)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
combined := append(result.UserRules, classify.DefaultRules...)
if len(combined) != len(classify.DefaultRules)+1 {
t.Errorf("combined rules len: got %d, want %d", len(combined), len(classify.DefaultRules)+1)
}
// User rule should be first
if combined[0].Class != "MyService" {
t.Errorf("first rule should be user rule 'MyService', got %q", combined[0].Class)
}
}
// TestAutoFreqAssignment: TOML with [[rules]] (class="GameServer", protocol="tcp") and
// NO [sounds.GameServer] -> FreqCfgs contains "GameServer" entry with BaseHz in [1200, 2350]
// and WaveformType == WaveformSine.
func TestAutoFreqAssignment(t *testing.T) {
toml := `
[[rules]]
protocol = "tcp"
class = "GameServer"
`
path := writeTOML(t, toml)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cfg, ok := result.FreqCfgs["GameServer"]
if !ok {
t.Fatal("FreqCfgs should contain 'GameServer' entry from auto-freq assignment")
}
if cfg.BaseHz < 1200.0 || cfg.BaseHz > 2350.0 {
t.Errorf("GameServer BaseHz: got %v, want in [1200, 2350]", cfg.BaseHz)
}
if cfg.WaveformType != synth.WaveformSine {
t.Errorf("GameServer WaveformType: got %v, want WaveformSine", cfg.WaveformType)
}
}
// TestAutoFreqDeterministic: Two Load() calls with same class name produce same BaseHz.
func TestAutoFreqDeterministic(t *testing.T) {
toml := `
[[rules]]
protocol = "tcp"
class = "MyDeterministicClass"
`
path := writeTOML(t, toml)
result1, err := config.Load(path)
if err != nil {
t.Fatalf("Load (1): %v", err)
}
result2, err := config.Load(path)
if err != nil {
t.Fatalf("Load (2): %v", err)
}
hz1 := result1.FreqCfgs["MyDeterministicClass"].BaseHz
hz2 := result2.FreqCfgs["MyDeterministicClass"].BaseHz
if hz1 != hz2 {
t.Errorf("auto-freq not deterministic: first=%v, second=%v", hz1, hz2)
}
}
// --- PrintConfig tests ---
// TestPrintConfigContainsAllClasses: defaults LoadResult produces output with all 14 class names.
func TestPrintConfigContainsAllClasses(t *testing.T) {
t.Chdir(t.TempDir())
result, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
classNames := []string{
"ICMP", "DNS", "HTTPS", "HTTP", "SSH", "SMTP",
"NTP", "DHCP", "other-TCP", "other-UDP",
"unknown-1", "unknown-2", "unknown-3", "unknown-4",
}
for _, name := range classNames {
if !strings.Contains(output, name) {
t.Errorf("PrintConfig output missing class %q", name)
}
}
}
// TestPrintConfigSourcePath: LoadResult with ConfigPath set shows the path in header.
func TestPrintConfigSourcePath(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
if !strings.Contains(output, "# Config source: "+path) {
t.Errorf("expected output to contain '# Config source: %s', got:\n%s", path, output)
}
}
// TestPrintConfigNoSourcePath: LoadResult with no ConfigPath shows "none" in header.
func TestPrintConfigNoSourcePath(t *testing.T) {
t.Chdir(t.TempDir())
result, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
if !strings.Contains(output, "# Config source: none") {
t.Errorf("expected output to contain '# Config source: none', got:\n%s", output)
}
}
// TestPrintConfigContainsRules: LoadResult with user rules emits [[rules]] section.
func TestPrintConfigContainsRules(t *testing.T) {
tomlContent := `
[[rules]]
port = 8080
protocol = "tcp"
class = "MyApp"
`
path := writeTOML(t, tomlContent)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
if !strings.Contains(output, "[[rules]]") {
t.Errorf("expected output to contain '[[rules]]', got:\n%s", output)
}
if !strings.Contains(output, "port = 8080") {
t.Errorf("expected output to contain 'port = 8080', got:\n%s", output)
}
if !strings.Contains(output, `protocol = "tcp"`) {
t.Errorf("expected output to contain 'protocol = \"tcp\"', got:\n%s", output)
}
if !strings.Contains(output, `class = "MyApp"`) {
t.Errorf("expected output to contain 'class = \"MyApp\"', got:\n%s", output)
}
}
// TestPrintConfigRuleNoPort: rule with DstPort==0 omits port line in output.
func TestPrintConfigRuleNoPort(t *testing.T) {
tomlContent := `
[[rules]]
protocol = "udp"
class = "AllUDP"
`
path := writeTOML(t, tomlContent)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
// Find the [[rules]] block and check no port line follows before the next blank line
rulesIdx := strings.Index(output, "[[rules]]")
if rulesIdx < 0 {
t.Fatal("expected [[rules]] in output")
}
rulesSection := output[rulesIdx:]
// Extract until the next blank line after [[rules]]
lines := strings.Split(rulesSection, "\n")
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "port =") {
t.Errorf("expected no 'port =' line for rule with DstPort=0, got line: %q", line)
}
if line == "" {
break // end of this rule block
}
}
}
// TestPrintConfigDefaultAnnotation: default ICMP entry annotated as (default).
func TestPrintConfigDefaultAnnotation(t *testing.T) {
t.Chdir(t.TempDir())
result, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
if !strings.Contains(output, "(default)") {
t.Errorf("expected '(default)' annotation in output, got:\n%s", output)
}
// Specifically check ICMP line
if !strings.Contains(output, "ICMP") {
t.Errorf("expected ICMP in output")
}
}
// TestPrintConfigOverrideAnnotation: ICMP with changed BaseHz annotated as (override).
func TestPrintConfigOverrideAnnotation(t *testing.T) {
path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
// Find the ICMP comment line and check annotation
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "# ICMP") {
if !strings.Contains(line, "(override)") {
t.Errorf("expected ICMP comment to contain '(override)', got: %q", line)
}
return
}
}
t.Error("did not find ICMP comment line in output")
}
// TestPrintConfigAutoAssignedAnnotation: user-defined class gets (auto-assigned) annotation.
func TestPrintConfigAutoAssignedAnnotation(t *testing.T) {
tomlContent := `
[[rules]]
protocol = "tcp"
class = "GameServer"
`
path := writeTOML(t, tomlContent)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
output := config.PrintConfig(result)
if !strings.Contains(output, "(auto-assigned)") {
t.Errorf("expected '(auto-assigned)' annotation in output, got:\n%s", output)
}
if !strings.Contains(output, "GameServer") {
t.Errorf("expected GameServer in output")
}
}
// TestAutoFreqSkipsBuiltins: TOML with [[rules]] (class="HTTPS", protocol="tcp", port=443)
// -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value.
func TestAutoFreqSkipsBuiltins(t *testing.T) {
toml := `
[[rules]]
protocol = "tcp"
port = 443
class = "HTTPS"
`
path := writeTOML(t, toml)
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if result.FreqCfgs[classify.ClassHTTPS].BaseHz != 175.0 {
t.Errorf("HTTPS BaseHz: got %v, want 175.0 (default, not auto-assigned)", result.FreqCfgs[classify.ClassHTTPS].BaseHz)
}
}
// TestLoadResultConfigPath: Load(explicit_path) -> LoadResult.ConfigPath == explicit_path;
// Load("") with no file -> LoadResult.ConfigPath == "".
func TestLoadResultConfigPath(t *testing.T) {
// explicit path
path := writeTOML(t, "[sounds.ICMP]\nfrequency = 100.0\n")
result, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if result.ConfigPath != path {
t.Errorf("ConfigPath: got %q, want %q", result.ConfigPath, path)
}
// empty path with no config file
t.Chdir(t.TempDir())
result2, err := config.Load("")
if err != nil {
t.Fatalf("Load with no config: %v", err)
}
if result2.ConfigPath != "" {
t.Errorf("ConfigPath for no-config: got %q, want %q", result2.ConfigPath, "")
}
}
// TestLoadNoConfigReturnsLoadResult: Load("") in empty dir returns LoadResult with
// len(FreqCfgs)==14, len(UserRules)==0, ConfigPath=="".
func TestLoadNoConfigReturnsLoadResult(t *testing.T) {
t.Chdir(t.TempDir())
result, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(result.FreqCfgs) != 14 {
t.Errorf("FreqCfgs len: got %d, want 14", len(result.FreqCfgs))
}
if len(result.UserRules) != 0 {
t.Errorf("UserRules len: got %d, want 0", len(result.UserRules))
}
if result.ConfigPath != "" {
t.Errorf("ConfigPath: got %q, want %q", result.ConfigPath, "")
}
}
+3 -2
View File
@@ -41,9 +41,10 @@ func EncodeMP3(outputPath string, frames [][2]float64, sampleRate int) error {
// RunSynthesis consumes a slice of WindowSnapshots, renders audio via OscillatorBank,
// and encodes to MP3 at outputPath.
// freqCfgs is the merged config map from config.Load (D-10: injected, not hardcoded).
// Returns an error if zero packets were captured (D-16 / OUT-03).
// The zero-packet guard runs BEFORE file creation to avoid leaving an empty file on disk.
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error {
func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string, freqCfgs map[classify.TrafficClass]synth.FreqConfig) error {
// D-16 / OUT-03: Zero-packet guard — check BEFORE creating output file
var totalPackets int64
for _, snap := range snapshots {
@@ -54,7 +55,7 @@ func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error
}
// Render all windows to stereo frames
bank := synth.NewBank(1.0) // tau=1.0s per D-07
bank := synth.NewBank(1.0, freqCfgs) // tau=1.0s per D-07
var allFrames [][2]float64
for _, snap := range snapshots {
frames := bank.RenderWindow(snap)
+3 -3
View File
@@ -53,7 +53,7 @@ func TestMP3Valid(t *testing.T) {
tmpFile.Close()
defer os.Remove(tmpPath)
if err := RunSynthesis(snaps, tmpPath); err != nil {
if err := RunSynthesis(snaps, tmpPath, synth.ClassFreqConfigs); err != nil {
t.Fatalf("RunSynthesis: %v", err)
}
@@ -98,7 +98,7 @@ func TestZeroPacketError(t *testing.T) {
tmpPath := "/tmp/netsynth-should-not-exist-" + t.Name() + ".mp3"
defer os.Remove(tmpPath)
err := RunSynthesis([]classify.WindowSnapshot{}, tmpPath)
err := RunSynthesis([]classify.WindowSnapshot{}, tmpPath, synth.ClassFreqConfigs)
if err == nil {
t.Fatal("expected error for empty snapshot slice, got nil")
}
@@ -118,7 +118,7 @@ func TestZeroPacketError(t *testing.T) {
{Counts: map[classify.TrafficClass]int64{}, TotalPackets: 0, WindowIndex: 0},
{Counts: map[classify.TrafficClass]int64{}, TotalPackets: 0, WindowIndex: 1},
}
err2 := RunSynthesis(zeroSnaps, tmpPath2)
err2 := RunSynthesis(zeroSnaps, tmpPath2, synth.ClassFreqConfigs)
if err2 == nil {
t.Fatal("expected error for zero-packet snapshots, got nil")
}
+3 -2
View File
@@ -5,14 +5,15 @@ go 1.24.1
require (
github.com/gopacket/gopacket v1.5.0
github.com/packetcap/go-pcap v0.0.0-20251215121130-f2cf9f991e7c
github.com/sjzar/go-lame v0.0.9
github.com/spf13/cobra v1.10.2
golang.org/x/net v0.39.0
)
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sjzar/go-lame v0.0.9 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
)
+6
View File
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -22,6 +24,10 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg=
github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+15 -13
View File
@@ -2,22 +2,25 @@ package synth
import "github.com/netsynth/netsynth/classify"
// OscillatorBank holds 11 synthesis layers, one per TrafficClass.
// OscillatorBank holds synthesis layers, one per TrafficClass in the injected config map.
// It consumes WindowSnapshot data and renders stereo PCM frames.
type OscillatorBank struct {
layers map[classify.TrafficClass]*Layer
tau float64
gainPerLayer float64
}
// NewBank creates an OscillatorBank with one Layer per TrafficClass.
// NewBank creates an OscillatorBank with one Layer per entry in cfgs.
// tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel).
func NewBank(tau float64) *OscillatorBank {
// gainPerLayer is computed dynamically as 1/len(cfgs) so that all layers at max
// amplitude sum to exactly 1.0 (no clipping), regardless of how many classes are active.
func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank {
b := &OscillatorBank{
layers: make(map[classify.TrafficClass]*Layer, NumLayers),
layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
tau: tau,
gainPerLayer: 1.0 / float64(len(cfgs)),
}
for _, class := range classify.AllClasses() {
cfg := ClassFreqConfigs[class]
for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau)
}
return b
@@ -25,7 +28,7 @@ func NewBank(tau float64) *OscillatorBank {
// RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow
// stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1].
// Per D-10: each layer gets GainPerLayer (1/11) so 11 max-amplitude layers sum to 1.0 (no clipping).
// Each layer gets 1/N of the total gain where N is the number of layers.
func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 {
// Find max count for normalization
var maxCount int64
@@ -36,21 +39,20 @@ func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64
}
// Update target amplitudes for all layers
for _, class := range classify.AllClasses() {
for class, layer := range b.layers {
count := snap.Counts[class]
b.layers[class].UpdateTarget(count, maxCount)
layer.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]
for _, layer := range b.layers {
sample := layer.AdvanceSample()
gainL, gainR := PanGains(layer.Config.Pan)
sumL += sample * GainPerLayer * gainL
sumR += sample * GainPerLayer * gainR
sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR
}
frames[i] = [2]float64{sumL, sumR}
}
+47 -9
View File
@@ -8,7 +8,7 @@ import (
)
func TestNewBankHas14Layers(t *testing.T) {
b := NewBank(1.0)
b := NewBank(1.0, ClassFreqConfigs)
if len(b.layers) != 14 {
t.Errorf("NewBank() has %d layers, want 14", len(b.layers))
}
@@ -21,7 +21,7 @@ func TestNewBankHas14Layers(t *testing.T) {
}
func TestRenderWindowOutputLength(t *testing.T) {
b := NewBank(1.0)
b := NewBank(1.0, ClassFreqConfigs)
snap := classify.WindowSnapshot{
Counts: make(map[classify.TrafficClass]int64),
TotalPackets: 0,
@@ -34,7 +34,7 @@ func TestRenderWindowOutputLength(t *testing.T) {
}
func TestRenderWindowSilentWhenNoTraffic(t *testing.T) {
b := NewBank(1.0)
b := NewBank(1.0, ClassFreqConfigs)
// Empty counts — no class ever seen — all layers should stay at zero amplitude
snap := classify.WindowSnapshot{
Counts: make(map[classify.TrafficClass]int64),
@@ -51,7 +51,7 @@ func TestRenderWindowSilentWhenNoTraffic(t *testing.T) {
}
func TestRenderWindowNonZeroWithTraffic(t *testing.T) {
b := NewBank(1.0)
b := NewBank(1.0, ClassFreqConfigs)
counts := make(map[classify.TrafficClass]int64)
counts[classify.ClassICMP] = 100
snap := classify.WindowSnapshot{
@@ -74,15 +74,15 @@ func TestRenderWindowNonZeroWithTraffic(t *testing.T) {
}
func TestMixerNoClip(t *testing.T) {
b := NewBank(0.01) // fast EMA to quickly ramp up to near-max amplitude
b := NewBank(0.01, ClassFreqConfigs) // fast EMA to quickly ramp up to near-max amplitude
counts := make(map[classify.TrafficClass]int64)
// All 14 classes at max count — worst-case mixing scenario
for _, class := range classify.AllClasses() {
for class := range ClassFreqConfigs {
counts[class] = 1000
}
snap := classify.WindowSnapshot{
Counts: counts,
TotalPackets: 14000,
TotalPackets: int64(len(ClassFreqConfigs)) * 1000,
WindowIndex: 0,
}
// Render multiple windows to let EMA converge
@@ -102,7 +102,7 @@ func TestMixerNoClip(t *testing.T) {
}
func TestStereoPan(t *testing.T) {
b := NewBank(0.01) // fast EMA
b := NewBank(0.01, ClassFreqConfigs) // fast EMA
counts := make(map[classify.TrafficClass]int64)
// ClassDHCP has pan=-0.75 (wide-left in config.go)
counts[classify.ClassDHCP] = 1000
@@ -130,7 +130,7 @@ func TestStereoPan(t *testing.T) {
}
func TestMultipleWindowsEMAConvergence(t *testing.T) {
b := NewBank(1.0)
b := NewBank(1.0, ClassFreqConfigs)
counts := make(map[classify.TrafficClass]int64)
counts[classify.ClassICMP] = 100
snap := classify.WindowSnapshot{
@@ -150,6 +150,44 @@ func TestMultipleWindowsEMAConvergence(t *testing.T) {
}
}
func TestNewBankDynamicGain(t *testing.T) {
// Create a config map with only 3 classes
cfgs := map[classify.TrafficClass]FreqConfig{
classify.ClassICMP: ClassFreqConfigs[classify.ClassICMP],
classify.ClassDNS: ClassFreqConfigs[classify.ClassDNS],
classify.ClassHTTPS: ClassFreqConfigs[classify.ClassHTTPS],
}
b := NewBank(0.01, cfgs)
if len(b.layers) != 3 {
t.Errorf("NewBank with 3 configs has %d layers, want 3", len(b.layers))
}
// Verify gainPerLayer is 1/3
expected := 1.0 / 3.0
if b.gainPerLayer != expected {
t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected)
}
}
func TestNewBankCustomConfigNoClip(t *testing.T) {
cfgs := map[classify.TrafficClass]FreqConfig{
classify.ClassICMP: ClassFreqConfigs[classify.ClassICMP],
classify.ClassDNS: ClassFreqConfigs[classify.ClassDNS],
}
b := NewBank(0.01, cfgs)
counts := map[classify.TrafficClass]int64{
classify.ClassICMP: 1000,
classify.ClassDNS: 1000,
}
snap := classify.WindowSnapshot{Counts: counts, TotalPackets: 2000, WindowIndex: 0}
for i := 0; i < 10; i++ {
for _, frame := range b.RenderWindow(snap) {
if frame[0] > 1.0 || frame[0] < -1.0 || frame[1] > 1.0 || frame[1] < -1.0 {
t.Fatalf("clipped with 2-class config: L=%v R=%v", frame[0], frame[1])
}
}
}
}
// windowRMS computes the root mean square amplitude across all stereo frames.
func windowRMS(frames [][2]float64) float64 {
var sum float64
+62 -14
View File
@@ -11,6 +11,53 @@ const (
WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude
)
// WaveformType selects the harmonic preset for a synthesis layer.
// The zero value WaveformCustom preserves existing hand-tuned harmonics in FreqConfig.Harmonics.
type WaveformType int
const (
WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is
WaveformSine // pure fundamental, single harmonic
WaveformSquare // odd harmonics with 1/k amplitude (bandlimited)
WaveformSawtooth // all harmonics with 1/k amplitude (bandlimited)
WaveformTriangle // odd harmonics with alternating 1/k^2 amplitude (bandlimited)
)
// WaveformPresetHarmonics returns the bandlimited harmonic series for the given waveform type
// at the given base frequency and sample rate. Returns nil for WaveformCustom.
// All returned harmonics are below Nyquist (sampleRate/2).
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef {
nyquist := float64(sampleRate) / 2.0
switch wt {
case WaveformCustom:
return nil
case WaveformSine:
return []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
case WaveformSquare:
var harmonics []HarmonicDef
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
}
return harmonics
case WaveformSawtooth:
var harmonics []HarmonicDef
for k := 1; float64(k)*baseHz < nyquist; k++ {
harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
}
return harmonics
case WaveformTriangle:
var harmonics []HarmonicDef
sign := 1.0
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)})
sign = -sign
}
return harmonics
default:
return nil
}
}
// HarmonicDef defines one partial in an additive synthesizer.
type HarmonicDef struct {
Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc.
@@ -22,26 +69,27 @@ type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right
WaveformType WaveformType // zero value WaveformCustom uses Harmonics as-is
}
// 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.ClassICMP: {BaseHz: 65.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, Pan: 0.0},
classify.ClassDNS: {BaseHz: 110.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, Pan: -0.2},
classify.ClassHTTPS: {BaseHz: 175.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, Pan: 0.2},
classify.ClassHTTP: {BaseHz: 220.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, Pan: -0.35},
classify.ClassSSH: {BaseHz: 330.0, Harmonics: []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, Pan: 0.35},
classify.ClassSMTP: {BaseHz: 440.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, Pan: -0.55},
classify.ClassNTP: {BaseHz: 520.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.25}}, Pan: 0.55},
classify.ClassDHCP: {BaseHz: 600.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, Pan: -0.75},
classify.ClassOtherTCP: {BaseHz: 700.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.2}}, Pan: 0.75},
classify.ClassOtherUDP: {BaseHz: 780.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.2}}, Pan: -0.75},
// 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},
classify.ClassUnknown1: {BaseHz: 862.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: 0.6},
classify.ClassUnknown2: {BaseHz: 920.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: -0.6},
classify.ClassUnknown3: {BaseHz: 981.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: 0.9},
classify.ClassUnknown4: {BaseHz: 1047.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: -0.9},
}
+3 -2
View File
@@ -59,7 +59,8 @@ func TestClassFreqConfigsComplete(t *testing.T) {
}
func TestNumLayersMatchesAllClasses(t *testing.T) {
if synth.NumLayers != len(classify.AllClasses()) {
t.Errorf("NumLayers=%d but AllClasses() has %d entries", synth.NumLayers, len(classify.AllClasses()))
if len(synth.ClassFreqConfigs) != len(classify.AllClasses()) {
t.Errorf("ClassFreqConfigs has %d entries but AllClasses() has %d entries",
len(synth.ClassFreqConfigs), len(classify.AllClasses()))
}
}
+4
View File
@@ -20,7 +20,11 @@ type Layer struct {
}
// NewLayer creates a Layer for the given config using the specified sample rate and EMA time constant (tau in seconds).
// If cfg.WaveformType is not WaveformCustom, harmonics are resolved from the preset at construction time.
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
}
return &Layer{
Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate),
+201
View File
@@ -0,0 +1,201 @@
package synth_test
import (
"testing"
"github.com/netsynth/netsynth/synth"
)
func TestWaveformPresetHarmonics_Sine(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSine, 440.0, 44100)
if len(harmonics) != 1 {
t.Fatalf("WaveformSine: expected 1 harmonic, got %d", len(harmonics))
}
if harmonics[0].Ratio != 1 {
t.Errorf("WaveformSine: expected Ratio=1, got %d", harmonics[0].Ratio)
}
if harmonics[0].Amplitude != 1.0 {
t.Errorf("WaveformSine: expected Amplitude=1.0, got %.4f", harmonics[0].Amplitude)
}
}
func TestWaveformPresetHarmonics_Square(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSquare, 440.0, 44100)
if len(harmonics) == 0 {
t.Fatal("WaveformSquare: expected at least one harmonic, got 0")
}
nyquist := 22050.0
for _, h := range harmonics {
if float64(h.Ratio)*440.0 >= nyquist {
t.Errorf("WaveformSquare: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0)
}
expectedAmp := 1.0 / float64(h.Ratio)
if abs(h.Amplitude-expectedAmp) > 1e-9 {
t.Errorf("WaveformSquare: harmonic %d: expected amplitude %.6f, got %.6f", h.Ratio, expectedAmp, h.Amplitude)
}
}
}
func TestWaveformPresetHarmonics_Sawtooth(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSawtooth, 440.0, 44100)
if len(harmonics) == 0 {
t.Fatal("WaveformSawtooth: expected at least one harmonic, got 0")
}
nyquist := 22050.0
for _, h := range harmonics {
if float64(h.Ratio)*440.0 >= nyquist {
t.Errorf("WaveformSawtooth: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0)
}
expectedAmp := 1.0 / float64(h.Ratio)
if abs(h.Amplitude-expectedAmp) > 1e-9 {
t.Errorf("WaveformSawtooth: harmonic %d: expected amplitude %.6f, got %.6f", h.Ratio, expectedAmp, h.Amplitude)
}
}
}
func TestWaveformPresetHarmonics_Triangle(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformTriangle, 440.0, 44100)
if len(harmonics) == 0 {
t.Fatal("WaveformTriangle: expected at least one harmonic, got 0")
}
nyquist := 22050.0
sign := 1.0
for i, h := range harmonics {
if float64(h.Ratio)*440.0 >= nyquist {
t.Errorf("WaveformTriangle: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0)
}
expectedAmp := sign / float64(h.Ratio*h.Ratio)
if abs(h.Amplitude-expectedAmp) > 1e-9 {
t.Errorf("WaveformTriangle: harmonic %d (index %d): expected amplitude %.6f, got %.6f", h.Ratio, i, expectedAmp, h.Amplitude)
}
sign = -sign
}
}
func TestWaveformPresetHarmonics_Custom(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformCustom, 440.0, 44100)
if harmonics != nil {
t.Errorf("WaveformCustom: expected nil, got %v", harmonics)
}
}
func TestBandlimitedHarmonicsNoAliasing(t *testing.T) {
waveforms := []synth.WaveformType{
synth.WaveformSine,
synth.WaveformSquare,
synth.WaveformSawtooth,
synth.WaveformTriangle,
}
for _, cfg := range synth.ClassFreqConfigs {
for _, wt := range waveforms {
harmonics := synth.WaveformPresetHarmonics(wt, cfg.BaseHz, 44100)
for _, h := range harmonics {
freq := float64(h.Ratio) * cfg.BaseHz
if freq >= 22050.0 {
t.Errorf("waveform %d, baseHz=%.1f: harmonic ratio %d produces freq=%.1f >= Nyquist 22050", wt, cfg.BaseHz, h.Ratio, freq)
}
}
}
}
}
func TestWaveformPresetHarmonics_SquareOddOnly(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSquare, 440.0, 44100)
for _, h := range harmonics {
if h.Ratio%2 == 0 {
t.Errorf("WaveformSquare: found even ratio %d (should be odd-only)", h.Ratio)
}
}
}
func TestWaveformPresetHarmonics_TriangleOddOnly(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformTriangle, 440.0, 44100)
for _, h := range harmonics {
if h.Ratio%2 == 0 {
t.Errorf("WaveformTriangle: found even ratio %d (should be odd-only)", h.Ratio)
}
}
}
func TestWaveformPresetHarmonics_SawtoothConsecutive(t *testing.T) {
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSawtooth, 440.0, 44100)
for i, h := range harmonics {
expected := i + 1
if h.Ratio != expected {
t.Errorf("WaveformSawtooth: index %d: expected ratio %d, got %d", i, expected, h.Ratio)
}
}
}
func TestNewLayerResolvesWaveformPreset(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
WaveformType: synth.WaveformSquare,
// Harmonics intentionally empty — preset should be resolved
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
if len(layer.Config.Harmonics) <= 1 {
t.Errorf("expected layer.Config.Harmonics to have length > 1 after preset resolution, got %d", len(layer.Config.Harmonics))
}
if layer.Config.Harmonics[0].Ratio != 1 {
t.Errorf("expected first harmonic Ratio=1, got %d", layer.Config.Harmonics[0].Ratio)
}
}
func TestNewLayerPreservesCustomHarmonics(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.4}},
// WaveformType zero value = WaveformCustom
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
if len(layer.Config.Harmonics) != 2 {
t.Errorf("expected exactly 2 harmonics preserved, got %d", len(layer.Config.Harmonics))
}
if layer.Config.Harmonics[1].Amplitude != 0.4 {
t.Errorf("expected second harmonic Amplitude=0.4, got %.4f", layer.Config.Harmonics[1].Amplitude)
}
}
func TestSineRegressionVsCustomHarmonics(t *testing.T) {
// Sine preset should produce identical output to a single-harmonic custom config
cfgSine := synth.FreqConfig{
BaseHz: 440.0,
WaveformType: synth.WaveformSine,
}
cfgCustom := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
}
// Use fast-converging tau for test
layerSine := synth.NewLayer(cfgSine, synth.SampleRate, 0.001)
layerCustom := synth.NewLayer(cfgCustom, synth.SampleRate, 0.001)
// Set both to same target amplitude
layerSine.UpdateTarget(1, 1)
layerCustom.UpdateTarget(1, 1)
// Advance enough samples for EMA to converge (tau=0.001 at 44100 SR: ~44 samples to 63%)
for i := 0; i < 200; i++ {
layerSine.AdvanceSample()
layerCustom.AdvanceSample()
}
// Next 100 samples should match exactly
for i := 0; i < 100; i++ {
s1 := layerSine.AdvanceSample()
s2 := layerCustom.AdvanceSample()
if abs(s1-s2) > 1e-12 {
t.Errorf("sample %d: sine preset (%.10f) != custom harmonic (%.10f), diff=%.2e", i, s1, s2, abs(s1-s2))
break
}
}
}
// abs returns the absolute value of x.
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}