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:
+19
-6
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user