Files
yoloyolo/.planning/research/ARCHITECTURE.md
T
2026-03-26 16:54:31 +01:00

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:

  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
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 []RuleNewClassifier(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