feat(synth): add LFO modulation, ADSR envelopes, pentatonic tuning, and soft limiter

Replace static EMA-smoothed drones with an evolving ambient soundscape:
- ADSR envelope system with sustained (2s attack, 4s release) and bursty
  (30ms attack, no sustain) modes per protocol group
- LFO pitch wobble and amplitude tremolo with incommensurable rates per
  group (Eno technique) so modulation patterns never repeat
- C major pentatonic frequency tuning (just intonation) — any combination
  of active protocols sounds consonant
- tanh soft limiter on master output prevents clipping
- Sync all documentation: README, PROJECT.md, ARCHITECTURE.md, v1.2
  requirements traceability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 19:38:58 +01:00
co-authored by Claude Opus 4.6
parent 494385b528
commit 6b2db48339
14 changed files with 824 additions and 255 deletions
+19 -6
View File
@@ -1,6 +1,10 @@
package synth
import "github.com/netsynth/netsynth/classify"
import (
"math"
"github.com/netsynth/netsynth/classify"
)
// OscillatorBank holds synthesis layers, one per TrafficClass in the injected config map.
// It consumes WindowSnapshot data and renders stereo PCM frames.
@@ -10,15 +14,18 @@ type OscillatorBank struct {
gainPerLayer float64
}
// maxTremoloDepth is the highest tremolo depth across all groups.
// Used to compute headroom so tremolo doesn't cause clipping.
const maxTremoloDepth = 0.20
// 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).
// 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.
// gainPerLayer accounts for tremolo headroom: 1 / (N * (1 + maxTremoloDepth)).
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)),
gainPerLayer: 1.0 / (float64(len(cfgs)) * (1.0 + maxTremoloDepth)),
}
for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau)
@@ -28,7 +35,7 @@ func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *Oscillator
// RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow
// stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1].
// Each layer gets 1/N of the total gain where N is the number of layers.
// Each layer gets gain with tremolo headroom. A soft limiter prevents any residual clipping.
func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 {
// Find max count for normalization
var maxCount int64
@@ -54,7 +61,13 @@ func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64
sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR
}
frames[i] = [2]float64{sumL, sumR}
frames[i] = [2]float64{softLimit(sumL), softLimit(sumR)}
}
return frames
}
// softLimit applies a tanh-based soft limiter to prevent clipping.
// Values within [-0.9, 0.9] pass nearly linearly; beyond that, they compress smoothly.
func softLimit(x float64) float64 {
return math.Tanh(x)
}
+9 -9
View File
@@ -129,24 +129,24 @@ func TestStereoPan(t *testing.T) {
}
}
func TestMultipleWindowsEMAConvergence(t *testing.T) {
func TestMultipleWindowsEnvelopeConvergence(t *testing.T) {
b := NewBank(1.0, ClassFreqConfigs)
counts := make(map[classify.TrafficClass]int64)
counts[classify.ClassICMP] = 100
counts[classify.ClassHTTPS] = 100 // sustained protocol — slow attack
snap := classify.WindowSnapshot{
Counts: counts,
TotalPackets: 100,
WindowIndex: 0,
}
// Compute RMS for first and last window render
// Compute RMS for first window render
rmsFirst := windowRMS(b.RenderWindow(snap))
// Render 4 more windows with the same snapshot
// Render more windows to let ADSR attack build up (2s attack at 500ms/window ≈ 4 windows)
var rmsLast float64
for i := 0; i < 4; i++ {
for i := 0; i < 8; i++ {
rmsLast = windowRMS(b.RenderWindow(snap))
}
if rmsLast <= rmsFirst {
t.Errorf("EMA should converge upward: rmsFirst=%v, rmsLast=%v", rmsFirst, rmsLast)
t.Errorf("envelope should converge upward: rmsFirst=%v, rmsLast=%v", rmsFirst, rmsLast)
}
}
@@ -161,9 +161,9 @@ func TestNewBankDynamicGain(t *testing.T) {
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 {
// Verify gainPerLayer accounts for tremolo headroom: 1/(3 * 1.2)
expected := 1.0 / (3.0 * (1.0 + maxTremoloDepth))
if math.Abs(b.gainPerLayer-expected) > 1e-12 {
t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected)
}
}
+146 -136
View File
@@ -69,302 +69,312 @@ type FreqConfig struct {
Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right
WaveformType WaveformType // zero value WaveformCustom uses Harmonics as-is
Group string // sound family: "Infrastructure", "Web", "Mail", "Remote Access", "Unknown", etc.
Bursty bool // true for event-like protocols (DNS, ICMP, NTP) — fast attack, no sustain
}
// Frequency Allocation Table (Phase 9 design — major-second ladder, 65-2449 Hz)
// Frequency Allocation Table — C Major Pentatonic, Just Intonation
//
// Slot Hz Class Group Waveform Pan
// 0 65 ICMP Infrastructure Triangle -0.3
// 1 73 NTP Infrastructure Triangle -0.1
// 2 82 DHCP Infrastructure Triangle 0.1
// 3 93 mDNS Infrastructure Triangle 0.3 (Phase 10)
// 4 105 SSDP Infrastructure Triangle -0.2 (Phase 10)
// 5 118 SNMP Infrastructure Triangle 0.2 (Phase 10)
// 6 133 DNS Infrastructure Triangle 0.0
// 7 150 HTTPS Web Sawtooth -0.4
// 8 169 HTTP Web Sawtooth -0.3
// 9 190 HTTP3 Web Sawtooth -0.2 (Phase 10)
// 10 214 SMTP Mail Triangle 0.2
// 11 241 IMAP Mail Triangle 0.3 (Phase 10)
// 12 271 POP3 Mail Triangle 0.4 (Phase 10)
// 13 305 SMTP-sub Mail Triangle 0.5 (Phase 10)
// 14 343 SSH Remote Access Square -0.7
// 15 385 RDP Remote Access Square -0.6 (Phase 10)
// 16 432 Telnet Remote Access Square -0.5 (Phase 10)
// 17 485 VNC Remote Access Square -0.4 (Phase 10)
// 18 545 FTP File Transfer Square 0.5 (Phase 10)
// 19 612 SMB File Transfer Square 0.6 (Phase 10)
// 20 687 TFTP File Transfer Square 0.7 (Phase 10)
// 21 771 unknown-1 Unknown Custom -0.9
// 22 866 unknown-2 Unknown Custom 0.9
// 23 972 unknown-3 Unknown Custom -0.7
// 24 1091 unknown-4 Unknown Custom 0.7
// 25 1225 other-TCP Unknown Custom -0.5
// 26 1375 other-UDP Unknown Custom 0.5
// 27 1543 MySQL Database Sawtooth -0.4 (Phase 10)
// 28 1732 PostgreSQL Database Sawtooth -0.2 (Phase 10)
// 29 1944 Redis Database Sawtooth 0.2 (Phase 10)
// 30 2182 MongoDB Database Sawtooth 0.4 (Phase 10)
// 31 2449 SIP VoIP Sine 0.0 (Phase 10)
// Scale: C D E G A across octaves 2-7 (ratios 1/1, 9/8, 5/4, 3/2, 5/3)
// Any combination of active tones is consonant — no dissonant intervals possible.
//
// Auto-assign range: [2500, 4000] Hz (see config/config.go)
// Slot Note Hz Class Group Waveform Pan Bursty
// 0 C2 65.4 ICMP Infrastructure Triangle -0.3 yes
// 1 D2 73.6 NTP Infrastructure Triangle -0.1 yes
// 2 E2 81.8 DHCP Infrastructure Triangle 0.1 yes
// 3 G2 98.0 mDNS Infrastructure Triangle 0.3 yes
// 4 A2 109.0 SSDP Infrastructure Triangle -0.2 yes
// 5 C3 130.8 SNMP Infrastructure Triangle 0.2 yes
// 6 D3 146.8 DNS Infrastructure Triangle 0.0 yes
// 7 E3 163.5 HTTPS Web Sawtooth -0.4 no
// 8 G3 196.0 HTTP Web Sawtooth -0.3 no
// 9 A3 218.0 QUIC Web Sawtooth -0.2 no
// 10 C4 261.6 SMTP Mail Triangle 0.2 no
// 11 D4 293.7 IMAP Mail Triangle 0.3 no
// 12 E4 327.0 POP3 Mail Triangle 0.4 no
// 13 G4 392.0 SMTP-sub Mail Triangle 0.5 no
// 14 A4 436.0 SSH Remote Access Square -0.7 no
// 15 C5 523.3 RDP Remote Access Square -0.6 no
// 16 D5 587.3 Telnet Remote Access Square -0.5 no
// 17 E5 654.1 VNC Remote Access Square -0.4 no
// 18 G5 784.0 FTP File Transfer Square 0.5 no
// 19 A5 872.1 SMB File Transfer Square 0.6 no
// 20 C6 1046.5 TFTP File Transfer Square 0.7 no
// 21 D6 1174.7 unknown-1 Unknown Custom -0.9 no
// 22 E6 1308.1 unknown-2 Unknown Custom 0.9 no
// 23 G6 1568.0 unknown-3 Unknown Custom -0.7 no
// 24 A6 1744.2 unknown-4 Unknown Custom 0.7 no
// 25 C7 2093.0 other-TCP Unknown Custom -0.5 no
// 26 D7 2349.3 other-UDP Unknown Custom 0.5 no
// 27 E7 2616.1 MySQL Database Sawtooth -0.4 no
// 28 G7 3136.0 PostgreSQL Database Sawtooth -0.2 no
// 29 A7 3488.4 Redis Database Sawtooth 0.2 no
// 30 C8 4186.0 MongoDB Database Sawtooth 0.4 no
// 31 D8 4704.0 SIP VoIP Sine 0.0 no
//
// Auto-assign range: [5000, 8000] Hz (see config/config.go)
// LDAP, Kerberos, Syslog: assigned to pentatonic slots in octave 3 (infrastructure)
// ClassFreqConfigs maps each traffic class to its synthesis parameters.
// Frequencies per Phase 9 major-second ladder design. Harmonics per D-05/D-06.
// Frequencies: C Major Pentatonic (just intonation) across octaves 2-8.
// Pan positions per D-12. Group field drives family-aware config output (GRP-04).
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{
// --- Infrastructure (Triangle, 65-133 Hz) ---
// --- Infrastructure (Triangle, C2-D3, bursty) ---
classify.ClassICMP: {
BaseHz: 65.0,
BaseHz: 65.4, // C2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 65.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 65.4, SampleRate),
Pan: -0.3,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassNTP: {
BaseHz: 73.0,
BaseHz: 73.6, // D2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 73.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 73.6, SampleRate),
Pan: -0.1,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassDHCP: {
BaseHz: 82.0,
BaseHz: 81.8, // E2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 82.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 81.8, SampleRate),
Pan: 0.1,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassMDNS: {
BaseHz: 98.0, // G2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 98.0, SampleRate),
Pan: 0.3,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassSSDP: {
BaseHz: 109.0, // A2
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 109.0, SampleRate),
Pan: -0.2,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassSNMP: {
BaseHz: 130.8, // C3
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 130.8, SampleRate),
Pan: 0.2,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassDNS: {
BaseHz: 133.0,
BaseHz: 146.8, // D3
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 133.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 146.8, SampleRate),
Pan: 0.0,
Group: "Infrastructure",
Bursty: true,
},
// --- Web (Sawtooth, 150-169 Hz) ---
// --- Web (Sawtooth, E3-A3, sustained) ---
classify.ClassHTTPS: {
BaseHz: 150.0,
BaseHz: 163.5, // E3
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 150.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 163.5, SampleRate),
Pan: -0.4,
Group: "Web",
},
classify.ClassHTTP: {
BaseHz: 169.0,
BaseHz: 196.0, // G3
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 169.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 196.0, SampleRate),
Pan: -0.3,
Group: "Web",
},
// --- Mail (Triangle, 214 Hz) ---
classify.ClassSMTP: {
BaseHz: 214.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 214.0, SampleRate),
Pan: 0.2,
Group: "Mail",
},
// --- Infrastructure additions (Triangle, 93-118 Hz) ---
classify.ClassMDNS: {
BaseHz: 93.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 93.0, SampleRate),
Pan: 0.3,
Group: "Infrastructure",
},
classify.ClassSSDP: {
BaseHz: 105.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 105.0, SampleRate),
Pan: -0.2,
Group: "Infrastructure",
},
classify.ClassSNMP: {
BaseHz: 118.0,
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 118.0, SampleRate),
Pan: 0.2,
Group: "Infrastructure",
},
// --- Web addition (Sawtooth, 190 Hz) ---
classify.ClassQUIC: {
BaseHz: 190.0,
BaseHz: 218.0, // A3
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 190.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 218.0, SampleRate),
Pan: -0.2,
Group: "Web",
},
// --- Mail additions (Triangle, 241-305 Hz) ---
classify.ClassIMAP: {
BaseHz: 241.0,
// --- Mail (Triangle, C4-G4) ---
classify.ClassSMTP: {
BaseHz: 261.6, // C4
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 241.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 261.6, SampleRate),
Pan: 0.2,
Group: "Mail",
},
classify.ClassIMAP: {
BaseHz: 293.7, // D4
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 293.7, SampleRate),
Pan: 0.3,
Group: "Mail",
},
classify.ClassPOP3: {
BaseHz: 271.0,
BaseHz: 327.0, // E4
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 271.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 327.0, SampleRate),
Pan: 0.4,
Group: "Mail",
},
classify.ClassSMTPSub: {
BaseHz: 305.0,
BaseHz: 392.0, // G4
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 305.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 392.0, SampleRate),
Pan: 0.5,
Group: "Mail",
},
// --- Remote Access (Square, 343 Hz) ---
// --- Remote Access (Square, A4-E5) ---
classify.ClassSSH: {
BaseHz: 343.0,
BaseHz: 436.0, // A4
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 343.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 436.0, SampleRate),
Pan: -0.7,
Group: "Remote Access",
},
// --- Remote Access additions (Square, 385-485 Hz) ---
classify.ClassRDP: {
BaseHz: 385.0,
BaseHz: 523.3, // C5
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 385.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 523.3, SampleRate),
Pan: -0.6,
Group: "Remote Access",
},
classify.ClassTelnet: {
BaseHz: 432.0,
BaseHz: 587.3, // D5
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 432.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 587.3, SampleRate),
Pan: -0.5,
Group: "Remote Access",
},
classify.ClassVNC: {
BaseHz: 485.0,
BaseHz: 654.1, // E5
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 485.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 654.1, SampleRate),
Pan: -0.4,
Group: "Remote Access",
},
// --- File Transfer additions (Square, 545-687 Hz) ---
// --- File Transfer (Square, G5-C6) ---
classify.ClassFTP: {
BaseHz: 545.0,
BaseHz: 784.0, // G5
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 545.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 784.0, SampleRate),
Pan: 0.5,
Group: "File Transfer",
},
classify.ClassSMB: {
BaseHz: 612.0,
BaseHz: 872.1, // A5
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 612.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 872.1, SampleRate),
Pan: 0.6,
Group: "File Transfer",
},
classify.ClassTFTP: {
BaseHz: 687.0,
BaseHz: 1046.5, // C6
WaveformType: WaveformSquare,
Harmonics: WaveformPresetHarmonics(WaveformSquare, 687.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSquare, 1046.5, SampleRate),
Pan: 0.7,
Group: "File Transfer",
},
// --- Unknown (Custom harmonics, 771-1375 Hz) ---
// D-05/D-06: dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} retained for all Unknown entries.
// WaveformType is zero value (WaveformCustom) so bank.go uses the stored Harmonics directly.
// --- Unknown (Custom harmonics, D6-D7) ---
// Dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} retained for all Unknown entries.
classify.ClassUnknown1: {
BaseHz: 771.0,
BaseHz: 1174.7, // D6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: -0.9,
Group: "Unknown",
},
classify.ClassUnknown2: {
BaseHz: 866.0,
BaseHz: 1308.1, // E6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: 0.9,
Group: "Unknown",
},
classify.ClassUnknown3: {
BaseHz: 972.0,
BaseHz: 1568.0, // G6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: -0.7,
Group: "Unknown",
},
classify.ClassUnknown4: {
BaseHz: 1091.0,
BaseHz: 1744.2, // A6
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: 0.7,
Group: "Unknown",
},
classify.ClassOtherTCP: {
BaseHz: 1225.0,
BaseHz: 2093.0, // C7
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: -0.5,
Group: "Unknown",
},
classify.ClassOtherUDP: {
BaseHz: 1375.0,
BaseHz: 2349.3, // D7
Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}},
Pan: 0.5,
Group: "Unknown",
},
// --- Database additions (Sawtooth, 1543-2182 Hz) ---
// --- Database (Sawtooth, E7-C8) ---
classify.ClassMySQL: {
BaseHz: 1543.0,
BaseHz: 2616.1, // E7
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1543.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 2616.1, SampleRate),
Pan: -0.4,
Group: "Database",
},
classify.ClassPostgreSQL: {
BaseHz: 1732.0,
BaseHz: 3136.0, // G7
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1732.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 3136.0, SampleRate),
Pan: -0.2,
Group: "Database",
},
classify.ClassRedis: {
BaseHz: 1944.0,
BaseHz: 3488.4, // A7
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 1944.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 3488.4, SampleRate),
Pan: 0.2,
Group: "Database",
},
classify.ClassMongoDB: {
BaseHz: 2182.0,
BaseHz: 4186.0, // C8
WaveformType: WaveformSawtooth,
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 2182.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSawtooth, 4186.0, SampleRate),
Pan: 0.4,
Group: "Database",
},
// --- VoIP (Sine, 2449 Hz) ---
// --- VoIP (Sine, D8) ---
classify.ClassSIP: {
BaseHz: 2449.0,
BaseHz: 4704.0, // D8
WaveformType: WaveformSine,
Harmonics: WaveformPresetHarmonics(WaveformSine, 2449.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformSine, 4704.0, SampleRate),
Pan: 0.0,
Group: "VoIP",
},
// --- Infrastructure auto-assigned (Triangle, 2950-3250 Hz) per D-02 ---
// --- Infrastructure auto-assigned (Triangle, pentatonic upper octaves) ---
classify.ClassLDAP: {
BaseHz: 2950.0,
BaseHz: 5232.0, // E8 — auto-assign range
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 2950.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 5232.0, SampleRate),
Pan: -0.2,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassKerberos: {
BaseHz: 3250.0,
BaseHz: 5878.0, // G8 (approx) — auto-assign range
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 3250.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 5878.0, SampleRate),
Pan: 0.0,
Group: "Infrastructure",
Bursty: true,
},
classify.ClassSyslog: {
BaseHz: 3050.0,
BaseHz: 6534.0, // A8 (approx) — auto-assign range
WaveformType: WaveformTriangle,
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 3050.0, SampleRate),
Harmonics: WaveformPresetHarmonics(WaveformTriangle, 6534.0, SampleRate),
Pan: 0.2,
Group: "Infrastructure",
Bursty: true,
},
}
+136
View File
@@ -0,0 +1,136 @@
package synth
// EnvelopeState tracks the current phase of an ADSR envelope.
type EnvelopeState int
const (
EnvIdle EnvelopeState = iota // silent, waiting for trigger
EnvAttack // ramping up to peak
EnvDecay // falling from peak to sustain level
EnvSustain // holding at sustain level (modulated by traffic rate)
EnvRelease // fading out after traffic stops
)
// Envelope is an ADSR envelope generator with exponential curves.
// It wraps the traffic-rate amplitude: the envelope shapes onset/offset,
// while the traffic rate modulates within the sustain phase.
type Envelope struct {
state EnvelopeState
level float64 // current envelope level [0, 1]
attackRate float64 // per-sample (exponential approach)
decayRate float64 // per-sample
sustainLevel float64 // target level during sustain [0, 1]
releaseRate float64 // per-sample
trafficAmp float64 // EMA-smoothed traffic amplitude [0, 1]
emaAlpha float64 // EMA coefficient for traffic smoothing
}
// EnvelopeParams configures ADSR timing.
type EnvelopeParams struct {
AttackSec float64 // seconds to reach peak
DecaySec float64 // seconds from peak to sustain level
SustainLevel float64 // sustain amplitude [0, 1]
ReleaseSec float64 // seconds to fade to silence
}
// Sustained flow envelope: slow, ambient feel.
var SustainedEnvParams = EnvelopeParams{
AttackSec: 2.0,
DecaySec: 1.0,
SustainLevel: 0.85,
ReleaseSec: 4.0,
}
// Bursty protocol envelope: percussive, event-like.
var BurstyEnvParams = EnvelopeParams{
AttackSec: 0.03,
DecaySec: 0.3,
SustainLevel: 0.0,
ReleaseSec: 1.5,
}
// NewEnvelope creates an ADSR envelope for the given params and sample rate.
// tau is the EMA time constant for traffic amplitude smoothing (seconds).
func NewEnvelope(params EnvelopeParams, sampleRate int, tau float64) *Envelope {
sr := float64(sampleRate)
return &Envelope{
state: EnvIdle,
attackRate: 1.0 / (params.AttackSec * sr),
decayRate: 1.0 / (params.DecaySec * sr),
sustainLevel: params.SustainLevel,
releaseRate: 1.0 / (params.ReleaseSec * sr),
emaAlpha: EMAAlpha(tau, sampleRate),
}
}
// Trigger starts the envelope (called when traffic first appears for this class).
func (e *Envelope) Trigger() {
if e.state == EnvIdle || e.state == EnvRelease {
e.state = EnvAttack
}
}
// Release begins the release phase (called when traffic stops).
func (e *Envelope) Release() {
if e.state != EnvIdle {
e.state = EnvRelease
}
}
// SetTrafficRate updates the EMA-smoothed traffic amplitude target.
// rate should be normalized [0, 1] (count / maxCount).
func (e *Envelope) SetTrafficRate(rate float64) {
e.trafficAmp += e.emaAlpha * (rate - e.trafficAmp)
}
// Advance processes one sample and returns the envelope amplitude [0, 1].
func (e *Envelope) Advance() float64 {
switch e.state {
case EnvIdle:
return 0
case EnvAttack:
e.level += e.attackRate * (1.05 - e.level) // overshoot target slightly for exponential feel
if e.level >= 1.0 {
e.level = 1.0
e.state = EnvDecay
}
case EnvDecay:
target := e.sustainLevel
e.level += e.decayRate * (target - e.level)
if e.level-target < 0.001 {
e.level = target
if target > 0 {
e.state = EnvSustain
} else {
// Bursty: sustain=0, go to release
e.state = EnvRelease
}
}
case EnvSustain:
// Modulate sustain level by traffic rate
target := e.sustainLevel * (WhisperFloor + (1.0-WhisperFloor)*e.trafficAmp)
e.level += e.emaAlpha * (target - e.level)
case EnvRelease:
e.level -= e.releaseRate * e.level
if e.level < 0.001 {
e.level = 0
e.state = EnvIdle
}
}
return e.level
}
// State returns the current envelope state (for testing).
func (e *Envelope) State() EnvelopeState {
return e.state
}
// Level returns the current envelope level (for testing).
func (e *Envelope) Level() float64 {
return e.level
}
+103
View File
@@ -0,0 +1,103 @@
package synth_test
import (
"testing"
"github.com/netsynth/netsynth/synth"
)
func TestEnvelopeStartsIdle(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
if env.State() != synth.EnvIdle {
t.Errorf("expected EnvIdle, got %v", env.State())
}
if env.Level() != 0 {
t.Errorf("expected level=0 in idle, got %v", env.Level())
}
}
func TestEnvelopeTriggerStartsAttack(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
if env.State() != synth.EnvAttack {
t.Errorf("expected EnvAttack after Trigger, got %v", env.State())
}
}
func TestEnvelopeAttackReachesPeak(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
// Advance through the full attack phase (2 seconds)
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
if env.Level() < 0.8 {
t.Errorf("expected level >= 0.8 after attack, got %v", env.Level())
}
}
func TestEnvelopeReleaseDecays(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
// Build up
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
peakLevel := env.Level()
env.Release()
// Advance through release (4 seconds)
for i := 0; i < synth.SampleRate*6; i++ {
env.Advance()
}
if env.Level() >= peakLevel*0.3 {
t.Errorf("expected level to decay well below peak after release, peak=%v, current=%v", peakLevel, env.Level())
}
}
func TestBurstyEnvelopeNoSustain(t *testing.T) {
env := synth.NewEnvelope(synth.BurstyEnvParams, synth.SampleRate, 1.0)
env.Trigger()
// Advance 2 seconds — bursty should have attacked, decayed (sustain=0), and be releasing
for i := 0; i < synth.SampleRate*2; i++ {
env.Advance()
}
// Should be very quiet (in release or idle)
if env.Level() > 0.1 {
t.Errorf("bursty envelope should be near zero after 2s, got %v (state=%v)", env.Level(), env.State())
}
}
func TestEnvelopeIdleOutputsZero(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
for i := 0; i < 100; i++ {
val := env.Advance()
if val != 0 {
t.Errorf("idle envelope should output 0, got %v at sample %d", val, i)
break
}
}
}
func TestEnvelopeRetriggerFromRelease(t *testing.T) {
env := synth.NewEnvelope(synth.SustainedEnvParams, synth.SampleRate, 1.0)
env.Trigger()
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
env.Release()
for i := 0; i < synth.SampleRate; i++ {
env.Advance()
}
levelBeforeRetrigger := env.Level()
// Re-trigger
env.Trigger()
for i := 0; i < synth.SampleRate*3; i++ {
env.Advance()
}
if env.Level() <= levelBeforeRetrigger {
t.Errorf("re-triggered envelope should rise above release level, before=%v, after=%v",
levelBeforeRetrigger, env.Level())
}
}
+55 -24
View File
@@ -8,64 +8,95 @@ func EMAAlpha(tau float64, sampleRate int) float64 {
return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate)))
}
// Layer combines an oscillator with EMA amplitude smoothing for one traffic class.
// Layer combines an oscillator with ADSR envelope, LFO modulation,
// and EMA amplitude smoothing for one traffic class.
type Layer struct {
Config FreqConfig
Osc *Oscillator
currentAmp float64
targetAmp float64
alpha float64 // EMA coefficient
env *Envelope
pitchLFO *LFO
tremoloLFO *LFO
seen bool // whether this class has ever had count > 0
whisper float64 // whisper floor amplitude (D-08)
}
// 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.
// LFO and ADSR parameters are derived from the protocol group and bursty flag.
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
}
// Select envelope params based on bursty flag
envParams := SustainedEnvParams
if cfg.Bursty {
envParams = BurstyEnvParams
}
// Get LFO config for this protocol group
lfoCfg := LFOConfigForGroup(cfg.Group)
return &Layer{
Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate),
alpha: EMAAlpha(tau, sampleRate),
whisper: WhisperFloor,
Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate),
env: NewEnvelope(envParams, sampleRate, tau),
pitchLFO: NewLFO(lfoCfg.PitchRate, lfoCfg.PitchDepth, sampleRate),
tremoloLFO: NewLFO(lfoCfg.TremoloRate, lfoCfg.TremoloDepth, sampleRate),
whisper: WhisperFloor,
}
}
// UpdateTarget sets the target amplitude from a packet count and max count across all classes.
// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate.
// UpdateTarget sets the envelope state and traffic rate from packet counts.
// Triggers the envelope on first appearance, releases when count drops to 0.
func (l *Layer) UpdateTarget(count int64, maxCount int64) {
if count > 0 {
l.seen = true
}
if !l.seen {
l.targetAmp = 0.0
return
if !l.seen {
l.seen = true
l.env.Trigger()
}
// Re-trigger if we were in release/idle
if l.env.State() == EnvRelease || l.env.State() == EnvIdle {
l.env.Trigger()
}
} else if l.seen && count == 0 {
l.env.Release()
}
// Update traffic rate for sustain modulation
normalizedRate := 0.0
if maxCount > 0 {
normalizedRate = float64(count) / float64(maxCount)
}
l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate
l.env.SetTrafficRate(normalizedRate)
}
// AdvanceSample renders one sample and advances the EMA amplitude toward target.
// Returns the raw mono sample (before pan/gain).
// AdvanceSample renders one sample with LFO modulation and ADSR envelope.
func (l *Layer) AdvanceSample() float64 {
// Pitch modulation: LFO shifts frequency by a few cents
pitchMod := l.pitchLFO.Advance()
l.Osc.freq = PitchLFOFreq(l.Config.BaseHz, pitchMod)
// Generate waveform sample
sample := l.Osc.Advance(l.Config.Harmonics)
l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp)
return sample * l.currentAmp
// Apply ADSR envelope
envAmp := l.env.Advance()
// Tremolo modulation: LFO modulates amplitude
tremoloMod := 1.0 + l.tremoloLFO.Advance() // [1-depth, 1+depth]
return sample * envAmp * tremoloMod
}
// CurrentAmp returns the current amplitude (for testing).
// CurrentAmp returns the current envelope level (for testing).
func (l *Layer) CurrentAmp() float64 {
return l.currentAmp
return l.env.Level()
}
// TargetAmp returns the target amplitude (for testing).
// TargetAmp returns the current traffic amplitude in the envelope (for testing).
func (l *Layer) TargetAmp() float64 {
return l.targetAmp
return l.env.trafficAmp
}
// Seen returns whether this layer has ever received traffic (for testing).
+75 -40
View File
@@ -6,83 +6,118 @@ import (
"github.com/netsynth/netsynth/synth"
)
func TestEMAAmplitudeRise(t *testing.T) {
cfg2 := synth.FreqConfig{
func TestEnvelopeAttackRise(t *testing.T) {
// Sustained protocol (Bursty=false): slow 2s attack
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Web",
}
layer := synth.NewLayer(cfg2, synth.SampleRate, 1.0)
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Force the layer to have been seen and set target to 1.0
// Trigger envelope by sending traffic
layer.UpdateTarget(100, 100)
// After 1 second (SampleRate samples) with tau=1.0, currentAmp should be > 0.5
// EMA: after tau seconds, amplitude reaches ~63% of target
for i := 0; i < synth.SampleRate; i++ {
// After 2 seconds (full attack), envelope level should be significant
for i := 0; i < synth.SampleRate*2; i++ {
layer.AdvanceSample()
}
if layer.CurrentAmp() <= 0.5 {
t.Errorf("expected currentAmp > 0.5 after 1 second rise, got %.4f", layer.CurrentAmp())
t.Errorf("expected envelope level > 0.5 after 2 second attack, got %.4f", layer.CurrentAmp())
}
}
func TestEMAAmplitudeDecay(t *testing.T) {
func TestBurstyEnvelopeFastAttack(t *testing.T) {
// Bursty protocol: fast 30ms attack
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Infrastructure",
Bursty: true,
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Set up: layer has been seen (currentAmp starts at 1.0) and target is whisper floor
// We'll manually prime by updating target with 100/100 first then re-route to whisper
layer.UpdateTarget(100, 100) // mark as seen, target=1.0
// Force currentAmp to 1.0 by running a few cycles at target 1.0
layer.UpdateTarget(100, 100)
// After 100ms, bursty envelope should have peaked and be decaying
for i := 0; i < synth.SampleRate/10; i++ {
layer.AdvanceSample()
}
// Bursty: sustain=0, so it should already be fading
// But it should have been triggered (not zero at peak)
if !layer.Seen() {
t.Error("expected layer.Seen() to be true after receiving traffic")
}
}
func TestEnvelopeRelease(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Web",
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Build up
layer.UpdateTarget(100, 100)
for i := 0; i < synth.SampleRate*3; i++ {
layer.AdvanceSample()
}
// Now decay: set count=0 (whisper floor kicks in)
peakAmp := layer.CurrentAmp()
// Release: set count to 0
layer.UpdateTarget(0, 100)
// After 1 second, currentAmp should be < 0.5
for i := 0; i < synth.SampleRate; i++ {
for i := 0; i < synth.SampleRate*5; i++ {
layer.AdvanceSample()
}
if layer.CurrentAmp() >= 0.5 {
t.Errorf("expected currentAmp < 0.5 after 1 second decay, got %.4f", layer.CurrentAmp())
// After 5 seconds of release (release time is 4s), should be much lower
if layer.CurrentAmp() >= peakAmp*0.5 {
t.Errorf("expected envelope to decay significantly, peak=%.4f, current=%.4f", peakAmp, layer.CurrentAmp())
}
}
func TestWhisperFloor(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Mark layer as seen by passing count=1, then set count=0
layer.UpdateTarget(1, 100)
layer.UpdateTarget(0, 100)
// Target should be whisper floor (not zero) because seen=true
if layer.TargetAmp() < synth.WhisperFloor {
t.Errorf("expected targetAmp >= WhisperFloor (%.2f) for seen layer at zero count, got %.4f",
synth.WhisperFloor, layer.TargetAmp())
}
}
func TestWhisperFloorNotSeenIsZero(t *testing.T) {
func TestLayerNotSeenIssilent(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Web",
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
// Never seen — UpdateTarget with zero count
layer.UpdateTarget(0, 100)
if layer.TargetAmp() != 0.0 {
t.Errorf("expected targetAmp == 0.0 for unseen layer, got %.4f", layer.TargetAmp())
if layer.Seen() {
t.Error("expected Seen()=false for layer that never had traffic")
}
// Advance some samples — output should be zero
for i := 0; i < 100; i++ {
sample := layer.AdvanceSample()
if sample != 0 {
t.Errorf("expected zero output for unseen layer, got %v at sample %d", sample, i)
break
}
}
}
func TestLayerSeenFlag(t *testing.T) {
cfg := synth.FreqConfig{
BaseHz: 440.0,
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
Pan: 0.0,
Group: "Web",
}
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
if layer.Seen() {
t.Error("expected Seen()=false initially")
}
layer.UpdateTarget(1, 100)
if !layer.Seen() {
t.Error("expected Seen()=true after receiving traffic")
}
}
+72
View File
@@ -0,0 +1,72 @@
package synth
import "math"
// LFO is a low-frequency oscillator for modulating synthesis parameters.
// Uses sine waveform. Rate is in Hz (typically 0.01-0.5 Hz for ambient feel).
type LFO struct {
phase float64
rate float64 // Hz
depth float64 // modulation depth (interpretation depends on usage)
sr float64
}
// NewLFO creates an LFO at the given rate (Hz) and depth.
func NewLFO(rate, depth float64, sampleRate int) *LFO {
return &LFO{
rate: rate,
depth: depth,
sr: float64(sampleRate),
}
}
// Advance returns the current LFO value in [-depth, +depth] and advances the phase.
func (l *LFO) Advance() float64 {
val := math.Sin(2 * math.Pi * l.phase)
l.phase += l.rate / l.sr
if l.phase >= 1.0 {
l.phase -= math.Floor(l.phase)
}
return val * l.depth
}
// PitchLFOFreq returns a pitch-modulated frequency given a base frequency
// and an LFO value in semitones. For example, lfoVal=0.1 shifts pitch up by 0.1 semitones.
func PitchLFOFreq(baseHz, lfoSemitones float64) float64 {
return baseHz * math.Pow(2.0, lfoSemitones/12.0)
}
// LFOConfig holds LFO parameters for a synthesis layer.
// Each protocol gets unique, incommensurable rates so modulation patterns never repeat.
type LFOConfig struct {
PitchRate float64 // Hz, typically 0.02-0.08
PitchDepth float64 // semitones, typically 0.05-0.15
TremoloRate float64 // Hz, typically 0.05-0.3
TremoloDepth float64 // amplitude fraction, typically 0.05-0.2
}
// layerLFOConfigs provides unique incommensurable LFO rates per protocol group.
// Rates chosen as non-integer ratios to avoid periodic sync (Eno technique).
var groupLFOConfigs = map[string]LFOConfig{
"Infrastructure": {PitchRate: 0.031, PitchDepth: 0.08, TremoloRate: 0.053, TremoloDepth: 0.12},
"Web": {PitchRate: 0.043, PitchDepth: 0.10, TremoloRate: 0.071, TremoloDepth: 0.15},
"Mail": {PitchRate: 0.037, PitchDepth: 0.07, TremoloRate: 0.059, TremoloDepth: 0.10},
"Remote Access": {PitchRate: 0.029, PitchDepth: 0.12, TremoloRate: 0.047, TremoloDepth: 0.18},
"File Transfer": {PitchRate: 0.041, PitchDepth: 0.09, TremoloRate: 0.067, TremoloDepth: 0.13},
"Database": {PitchRate: 0.023, PitchDepth: 0.06, TremoloRate: 0.083, TremoloDepth: 0.10},
"VoIP": {PitchRate: 0.019, PitchDepth: 0.05, TremoloRate: 0.091, TremoloDepth: 0.08},
"Unknown": {PitchRate: 0.053, PitchDepth: 0.15, TremoloRate: 0.037, TremoloDepth: 0.20},
}
// DefaultLFOConfig is the fallback for groups not in the map.
var DefaultLFOConfig = LFOConfig{
PitchRate: 0.033, PitchDepth: 0.10, TremoloRate: 0.057, TremoloDepth: 0.15,
}
// LFOConfigForGroup returns the LFO config for a protocol group.
func LFOConfigForGroup(group string) LFOConfig {
if cfg, ok := groupLFOConfigs[group]; ok {
return cfg
}
return DefaultLFOConfig
}
+90
View File
@@ -0,0 +1,90 @@
package synth_test
import (
"math"
"testing"
"github.com/netsynth/netsynth/synth"
)
func TestLFOBounds(t *testing.T) {
lfo := synth.NewLFO(1.0, 0.5, 44100) // 1 Hz, depth 0.5
for i := 0; i < 44100; i++ {
val := lfo.Advance()
if val < -0.5 || val > 0.5 {
t.Errorf("LFO value %v out of bounds [-0.5, 0.5] at sample %d", val, i)
break
}
}
}
func TestLFOPeriod(t *testing.T) {
// A 1 Hz LFO should complete one cycle in exactly SampleRate samples
lfo := synth.NewLFO(1.0, 1.0, 44100)
// Advance to first zero-crossing (quarter period)
var firstPositive float64
for i := 0; i < 44100; i++ {
val := lfo.Advance()
if i == 0 {
firstPositive = val
}
// After one full cycle, value should be close to the first value
if i == 44099 {
lastVal := val
// They won't be exactly equal due to phase advancement, but should be close
if math.Abs(lastVal-firstPositive) > 0.01 {
t.Errorf("LFO not periodic: first=%v, after 1 cycle=%v", firstPositive, lastVal)
}
}
}
}
func TestPitchLFOFreq(t *testing.T) {
// 0 semitones should return baseHz unchanged
if synth.PitchLFOFreq(440.0, 0.0) != 440.0 {
t.Errorf("PitchLFOFreq(440, 0) should be 440, got %v", synth.PitchLFOFreq(440.0, 0.0))
}
// 12 semitones = one octave up
result := synth.PitchLFOFreq(440.0, 12.0)
if math.Abs(result-880.0) > 0.01 {
t.Errorf("PitchLFOFreq(440, 12) should be 880, got %v", result)
}
// Small detuning: 0.1 semitones
result = synth.PitchLFOFreq(440.0, 0.1)
if result <= 440.0 || result >= 445.0 {
t.Errorf("PitchLFOFreq(440, 0.1) should be slightly above 440, got %v", result)
}
}
func TestLFOConfigForGroup(t *testing.T) {
cfg := synth.LFOConfigForGroup("Web")
if cfg.PitchRate == 0 {
t.Error("Web group should have non-zero PitchRate")
}
if cfg.TremoloRate == 0 {
t.Error("Web group should have non-zero TremoloRate")
}
// Unknown group should return default
cfg2 := synth.LFOConfigForGroup("NonexistentGroup")
if cfg2.PitchRate == 0 {
t.Error("fallback config should have non-zero PitchRate")
}
}
func TestGroupLFORatesIncommensurable(t *testing.T) {
// Verify that no two groups share the exact same pitch or tremolo rate
groups := []string{"Infrastructure", "Web", "Mail", "Remote Access", "File Transfer", "Database", "VoIP", "Unknown"}
pitchRates := make(map[float64]string)
tremoloRates := make(map[float64]string)
for _, g := range groups {
cfg := synth.LFOConfigForGroup(g)
if prev, exists := pitchRates[cfg.PitchRate]; exists {
t.Errorf("groups %q and %q share PitchRate=%v", prev, g, cfg.PitchRate)
}
pitchRates[cfg.PitchRate] = g
if prev, exists := tremoloRates[cfg.TremoloRate]; exists {
t.Errorf("groups %q and %q share TremoloRate=%v", prev, g, cfg.TremoloRate)
}
tremoloRates[cfg.TremoloRate] = g
}
}