16 KiB
Architecture Patterns
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
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)
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
What v1.1 Adds
Three independent but related features:
- TOML config file — override frequencies and waveforms per built-in class
- Additional waveforms — square, sawtooth, triangle alongside existing sine
- 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
config/
config.go Config struct, Load(path string) (*Config, error)
defaults.go DefaultConfig() — wraps existing ClassFreqConfigs values
TOML struct shape:
[[class]]
name = "HTTPS"
frequency_hz = 200.0
waveform = "sawtooth"
[[class]]
name = "myservice" # user-defined class (Feature 3)
frequency_hz = 350.0
waveform = "triangle"
The Config struct passed into NewBank should merge with ClassFreqConfigs:
// 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
}
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.
Feature 2: Additional Waveforms
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.
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)
}
}
Option B: Function field on Oscillator
Store waveFn func(phase float64) float64. More flexible but harder to serialize/configure.
Option A is preferred because waveform type maps cleanly to the TOML waveform string field without reflection tricks.
FreqConfig change: Add Waveform field:
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
Waveform Waveform // NEW: defaults to WaveformSine
}
NewLayer passes cfg.Waveform to NewOscillator. NewOscillator signature changes to accept the waveform.
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:
[[rule]]
protocol = "tcp"
dst_port = 8443
class = "myservice"
// 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
}
Merging in main.go:
userRules := config.ToClassifyRules(cfg.Rules) // []classify.Rule
allRules := append(userRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
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.
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.
New vs Modified Components
New
| 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 |
Modified
| 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 |
Data Flow Changes
v1.0 Flow (config hardcoded)
main.go
└─ classify.NewClassifier(classify.DefaultRules)
└─ encode.RunSynthesis(snapshots, path)
└─ synth.NewBank(1.0)
└─ ClassFreqConfigs[class] ← global, hardcoded
v1.1 Flow (config injected)
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
Suggested Build Order
The following order minimizes integration risk. Each step is independently testable before the next begins.
Step 1: Waveform types in synth/oscillator.go
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.
Files changed: synth/oscillator.go only.
Step 2: Wire Waveform through FreqConfig and Layer
Add Waveform to FreqConfig. Update NewLayer to pass it to NewOscillator. ClassFreqConfigs entries default to WaveformSine (zero value — valid if WaveformSine = 0).
Existing tests continue to pass without modification since all existing configs use the zero-value waveform.
Files changed: synth/config.go, synth/layer.go.
Step 3: Decouple NewBank from the global
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
- 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 v1.1 — custom sound mappings integration Researched: 2026-03-26