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:
@@ -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