2026-03-26 12:03:06 +01:00
|
|
|
package synth
|
|
|
|
|
|
2026-03-27 19:38:58 +01:00
|
|
|
import (
|
|
|
|
|
"math"
|
|
|
|
|
|
|
|
|
|
"github.com/netsynth/netsynth/classify"
|
|
|
|
|
)
|
2026-03-26 12:03:06 +01:00
|
|
|
|
2026-03-26 17:37:44 +01:00
|
|
|
// OscillatorBank holds synthesis layers, one per TrafficClass in the injected config map.
|
2026-03-26 12:03:06 +01:00
|
|
|
// It consumes WindowSnapshot data and renders stereo PCM frames.
|
|
|
|
|
type OscillatorBank struct {
|
2026-03-26 17:37:44 +01:00
|
|
|
layers map[classify.TrafficClass]*Layer
|
|
|
|
|
tau float64
|
|
|
|
|
gainPerLayer float64
|
2026-03-26 12:03:06 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 19:38:58 +01:00
|
|
|
// maxTremoloDepth is the highest tremolo depth across all groups.
|
|
|
|
|
// Used to compute headroom so tremolo doesn't cause clipping.
|
|
|
|
|
const maxTremoloDepth = 0.20
|
|
|
|
|
|
2026-03-26 17:37:44 +01:00
|
|
|
// NewBank creates an OscillatorBank with one Layer per entry in cfgs.
|
2026-03-26 12:03:06 +01:00
|
|
|
// tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel).
|
2026-03-27 19:38:58 +01:00
|
|
|
// gainPerLayer accounts for tremolo headroom: 1 / (N * (1 + maxTremoloDepth)).
|
2026-03-26 17:37:44 +01:00
|
|
|
func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank {
|
2026-03-26 12:03:06 +01:00
|
|
|
b := &OscillatorBank{
|
2026-03-26 17:37:44 +01:00
|
|
|
layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
|
|
|
|
|
tau: tau,
|
2026-03-27 19:38:58 +01:00
|
|
|
gainPerLayer: 1.0 / (float64(len(cfgs)) * (1.0 + maxTremoloDepth)),
|
2026-03-26 12:03:06 +01:00
|
|
|
}
|
2026-03-26 17:37:44 +01:00
|
|
|
for class, cfg := range cfgs {
|
2026-03-26 12:03:06 +01:00
|
|
|
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].
|
2026-03-27 19:38:58 +01:00
|
|
|
// Each layer gets gain with tremolo headroom. A soft limiter prevents any residual clipping.
|
2026-03-26 12:03:06 +01:00
|
|
|
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
|
2026-03-26 17:37:44 +01:00
|
|
|
for class, layer := range b.layers {
|
2026-03-26 12:03:06 +01:00
|
|
|
count := snap.Counts[class]
|
2026-03-26 17:37:44 +01:00
|
|
|
layer.UpdateTarget(count, maxCount)
|
2026-03-26 12:03:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render frames
|
|
|
|
|
frames := make([][2]float64, SamplesPerWindow)
|
|
|
|
|
for i := range frames {
|
|
|
|
|
var sumL, sumR float64
|
2026-03-26 17:37:44 +01:00
|
|
|
for _, layer := range b.layers {
|
2026-03-26 12:03:06 +01:00
|
|
|
sample := layer.AdvanceSample()
|
|
|
|
|
gainL, gainR := PanGains(layer.Config.Pan)
|
2026-03-26 17:37:44 +01:00
|
|
|
sumL += sample * b.gainPerLayer * gainL
|
|
|
|
|
sumR += sample * b.gainPerLayer * gainR
|
2026-03-26 12:03:06 +01:00
|
|
|
}
|
2026-03-27 19:38:58 +01:00
|
|
|
frames[i] = [2]float64{softLimit(sumL), softLimit(sumR)}
|
2026-03-26 12:03:06 +01:00
|
|
|
}
|
|
|
|
|
return frames
|
|
|
|
|
}
|
2026-03-27 19:38:58 +01:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|