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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user