Files
yoloyolo/synth/bank.go
T
gurixandClaude Opus 4.6 6b2db48339 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>
2026-03-27 19:38:58 +01:00

74 lines
2.3 KiB
Go

package synth
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.
type OscillatorBank struct {
layers map[classify.TrafficClass]*Layer
tau float64
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 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)) * (1.0 + maxTremoloDepth)),
}
for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau)
}
return b
}
// 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 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
for _, count := range snap.Counts {
if count > maxCount {
maxCount = count
}
}
// Update target amplitudes for all layers
for class, layer := range b.layers {
count := snap.Counts[class]
layer.UpdateTarget(count, maxCount)
}
// Render frames
frames := make([][2]float64, SamplesPerWindow)
for i := range frames {
var sumL, sumR float64
for _, layer := range b.layers {
sample := layer.AdvanceSample()
gainL, gainR := PanGains(layer.Config.Pan)
sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR
}
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)
}