Merge branch 'worktree-agent-a46f9aac'
This commit is contained in:
+62
-14
@@ -11,6 +11,53 @@ const (
|
||||
WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude
|
||||
)
|
||||
|
||||
// WaveformType selects the harmonic preset for a synthesis layer.
|
||||
// The zero value WaveformCustom preserves existing hand-tuned harmonics in FreqConfig.Harmonics.
|
||||
type WaveformType int
|
||||
|
||||
const (
|
||||
WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is
|
||||
WaveformSine // pure fundamental, single harmonic
|
||||
WaveformSquare // odd harmonics with 1/k amplitude (bandlimited)
|
||||
WaveformSawtooth // all harmonics with 1/k amplitude (bandlimited)
|
||||
WaveformTriangle // odd harmonics with alternating 1/k^2 amplitude (bandlimited)
|
||||
)
|
||||
|
||||
// WaveformPresetHarmonics returns the bandlimited harmonic series for the given waveform type
|
||||
// at the given base frequency and sample rate. Returns nil for WaveformCustom.
|
||||
// All returned harmonics are below Nyquist (sampleRate/2).
|
||||
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef {
|
||||
nyquist := float64(sampleRate) / 2.0
|
||||
switch wt {
|
||||
case WaveformCustom:
|
||||
return nil
|
||||
case WaveformSine:
|
||||
return []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
|
||||
case WaveformSquare:
|
||||
var harmonics []HarmonicDef
|
||||
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
|
||||
harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
|
||||
}
|
||||
return harmonics
|
||||
case WaveformSawtooth:
|
||||
var harmonics []HarmonicDef
|
||||
for k := 1; float64(k)*baseHz < nyquist; k++ {
|
||||
harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
|
||||
}
|
||||
return harmonics
|
||||
case WaveformTriangle:
|
||||
var harmonics []HarmonicDef
|
||||
sign := 1.0
|
||||
for k := 1; float64(k)*baseHz < nyquist; k += 2 {
|
||||
harmonics = append(harmonics, HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)})
|
||||
sign = -sign
|
||||
}
|
||||
return harmonics
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HarmonicDef defines one partial in an additive synthesizer.
|
||||
type HarmonicDef struct {
|
||||
Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc.
|
||||
@@ -22,26 +69,27 @@ type FreqConfig struct {
|
||||
BaseHz float64
|
||||
Harmonics []HarmonicDef
|
||||
Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right
|
||||
WaveformType WaveformType // zero value WaveformCustom uses Harmonics as-is
|
||||
}
|
||||
|
||||
// ClassFreqConfigs maps each traffic class to its synthesis parameters.
|
||||
// Frequencies use musical intervals per D-02/D-03. Harmonics per D-05/D-06.
|
||||
// Pan positions per D-12.
|
||||
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{
|
||||
classify.ClassICMP: {65.0, []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, 0.0},
|
||||
classify.ClassDNS: {110.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, -0.2},
|
||||
classify.ClassHTTPS: {175.0, []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, 0.2},
|
||||
classify.ClassHTTP: {220.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, -0.35},
|
||||
classify.ClassSSH: {330.0, []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, 0.35},
|
||||
classify.ClassSMTP: {440.0, []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, -0.55},
|
||||
classify.ClassNTP: {520.0, []HarmonicDef{{1, 1.0}, {2, 0.25}}, 0.55},
|
||||
classify.ClassDHCP: {600.0, []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, -0.75},
|
||||
classify.ClassOtherTCP: {700.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, 0.75},
|
||||
classify.ClassOtherUDP: {780.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, -0.75},
|
||||
classify.ClassICMP: {BaseHz: 65.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, Pan: 0.0},
|
||||
classify.ClassDNS: {BaseHz: 110.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, Pan: -0.2},
|
||||
classify.ClassHTTPS: {BaseHz: 175.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, Pan: 0.2},
|
||||
classify.ClassHTTP: {BaseHz: 220.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, Pan: -0.35},
|
||||
classify.ClassSSH: {BaseHz: 330.0, Harmonics: []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, Pan: 0.35},
|
||||
classify.ClassSMTP: {BaseHz: 440.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, Pan: -0.55},
|
||||
classify.ClassNTP: {BaseHz: 520.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.25}}, Pan: 0.55},
|
||||
classify.ClassDHCP: {BaseHz: 600.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, Pan: -0.75},
|
||||
classify.ClassOtherTCP: {BaseHz: 700.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.2}}, Pan: 0.75},
|
||||
classify.ClassOtherUDP: {BaseHz: 780.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.2}}, Pan: -0.75},
|
||||
// D-05: Unknown buckets in 850-1100 Hz dissonant range, detuned intervals
|
||||
// D-06: Same dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} for all 4
|
||||
classify.ClassUnknown1: {862.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.6},
|
||||
classify.ClassUnknown2: {920.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.6},
|
||||
classify.ClassUnknown3: {981.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.9},
|
||||
classify.ClassUnknown4: {1047.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.9},
|
||||
classify.ClassUnknown1: {BaseHz: 862.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: 0.6},
|
||||
classify.ClassUnknown2: {BaseHz: 920.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: -0.6},
|
||||
classify.ClassUnknown3: {BaseHz: 981.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: 0.9},
|
||||
classify.ClassUnknown4: {BaseHz: 1047.0, Harmonics: []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, Pan: -0.9},
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ type Layer struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
|
||||
if cfg.WaveformType != WaveformCustom {
|
||||
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
|
||||
}
|
||||
return &Layer{
|
||||
Config: cfg,
|
||||
Osc: NewOscillator(cfg.BaseHz, sampleRate),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package synth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netsynth/netsynth/synth"
|
||||
)
|
||||
|
||||
func TestWaveformPresetHarmonics_Sine(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSine, 440.0, 44100)
|
||||
if len(harmonics) != 1 {
|
||||
t.Fatalf("WaveformSine: expected 1 harmonic, got %d", len(harmonics))
|
||||
}
|
||||
if harmonics[0].Ratio != 1 {
|
||||
t.Errorf("WaveformSine: expected Ratio=1, got %d", harmonics[0].Ratio)
|
||||
}
|
||||
if harmonics[0].Amplitude != 1.0 {
|
||||
t.Errorf("WaveformSine: expected Amplitude=1.0, got %.4f", harmonics[0].Amplitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_Square(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSquare, 440.0, 44100)
|
||||
if len(harmonics) == 0 {
|
||||
t.Fatal("WaveformSquare: expected at least one harmonic, got 0")
|
||||
}
|
||||
nyquist := 22050.0
|
||||
for _, h := range harmonics {
|
||||
if float64(h.Ratio)*440.0 >= nyquist {
|
||||
t.Errorf("WaveformSquare: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0)
|
||||
}
|
||||
expectedAmp := 1.0 / float64(h.Ratio)
|
||||
if abs(h.Amplitude-expectedAmp) > 1e-9 {
|
||||
t.Errorf("WaveformSquare: harmonic %d: expected amplitude %.6f, got %.6f", h.Ratio, expectedAmp, h.Amplitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_Sawtooth(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSawtooth, 440.0, 44100)
|
||||
if len(harmonics) == 0 {
|
||||
t.Fatal("WaveformSawtooth: expected at least one harmonic, got 0")
|
||||
}
|
||||
nyquist := 22050.0
|
||||
for _, h := range harmonics {
|
||||
if float64(h.Ratio)*440.0 >= nyquist {
|
||||
t.Errorf("WaveformSawtooth: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0)
|
||||
}
|
||||
expectedAmp := 1.0 / float64(h.Ratio)
|
||||
if abs(h.Amplitude-expectedAmp) > 1e-9 {
|
||||
t.Errorf("WaveformSawtooth: harmonic %d: expected amplitude %.6f, got %.6f", h.Ratio, expectedAmp, h.Amplitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_Triangle(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformTriangle, 440.0, 44100)
|
||||
if len(harmonics) == 0 {
|
||||
t.Fatal("WaveformTriangle: expected at least one harmonic, got 0")
|
||||
}
|
||||
nyquist := 22050.0
|
||||
sign := 1.0
|
||||
for i, h := range harmonics {
|
||||
if float64(h.Ratio)*440.0 >= nyquist {
|
||||
t.Errorf("WaveformTriangle: harmonic ratio %d exceeds Nyquist (freq=%.1f)", h.Ratio, float64(h.Ratio)*440.0)
|
||||
}
|
||||
expectedAmp := sign / float64(h.Ratio*h.Ratio)
|
||||
if abs(h.Amplitude-expectedAmp) > 1e-9 {
|
||||
t.Errorf("WaveformTriangle: harmonic %d (index %d): expected amplitude %.6f, got %.6f", h.Ratio, i, expectedAmp, h.Amplitude)
|
||||
}
|
||||
sign = -sign
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_Custom(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformCustom, 440.0, 44100)
|
||||
if harmonics != nil {
|
||||
t.Errorf("WaveformCustom: expected nil, got %v", harmonics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandlimitedHarmonicsNoAliasing(t *testing.T) {
|
||||
waveforms := []synth.WaveformType{
|
||||
synth.WaveformSine,
|
||||
synth.WaveformSquare,
|
||||
synth.WaveformSawtooth,
|
||||
synth.WaveformTriangle,
|
||||
}
|
||||
for _, cfg := range synth.ClassFreqConfigs {
|
||||
for _, wt := range waveforms {
|
||||
harmonics := synth.WaveformPresetHarmonics(wt, cfg.BaseHz, 44100)
|
||||
for _, h := range harmonics {
|
||||
freq := float64(h.Ratio) * cfg.BaseHz
|
||||
if freq >= 22050.0 {
|
||||
t.Errorf("waveform %d, baseHz=%.1f: harmonic ratio %d produces freq=%.1f >= Nyquist 22050", wt, cfg.BaseHz, h.Ratio, freq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_SquareOddOnly(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSquare, 440.0, 44100)
|
||||
for _, h := range harmonics {
|
||||
if h.Ratio%2 == 0 {
|
||||
t.Errorf("WaveformSquare: found even ratio %d (should be odd-only)", h.Ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_TriangleOddOnly(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformTriangle, 440.0, 44100)
|
||||
for _, h := range harmonics {
|
||||
if h.Ratio%2 == 0 {
|
||||
t.Errorf("WaveformTriangle: found even ratio %d (should be odd-only)", h.Ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaveformPresetHarmonics_SawtoothConsecutive(t *testing.T) {
|
||||
harmonics := synth.WaveformPresetHarmonics(synth.WaveformSawtooth, 440.0, 44100)
|
||||
for i, h := range harmonics {
|
||||
expected := i + 1
|
||||
if h.Ratio != expected {
|
||||
t.Errorf("WaveformSawtooth: index %d: expected ratio %d, got %d", i, expected, h.Ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLayerResolvesWaveformPreset(t *testing.T) {
|
||||
cfg := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
WaveformType: synth.WaveformSquare,
|
||||
// Harmonics intentionally empty — preset should be resolved
|
||||
}
|
||||
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
|
||||
if len(layer.Config.Harmonics) <= 1 {
|
||||
t.Errorf("expected layer.Config.Harmonics to have length > 1 after preset resolution, got %d", len(layer.Config.Harmonics))
|
||||
}
|
||||
if layer.Config.Harmonics[0].Ratio != 1 {
|
||||
t.Errorf("expected first harmonic Ratio=1, got %d", layer.Config.Harmonics[0].Ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLayerPreservesCustomHarmonics(t *testing.T) {
|
||||
cfg := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.4}},
|
||||
// WaveformType zero value = WaveformCustom
|
||||
}
|
||||
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
|
||||
if len(layer.Config.Harmonics) != 2 {
|
||||
t.Errorf("expected exactly 2 harmonics preserved, got %d", len(layer.Config.Harmonics))
|
||||
}
|
||||
if layer.Config.Harmonics[1].Amplitude != 0.4 {
|
||||
t.Errorf("expected second harmonic Amplitude=0.4, got %.4f", layer.Config.Harmonics[1].Amplitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSineRegressionVsCustomHarmonics(t *testing.T) {
|
||||
// Sine preset should produce identical output to a single-harmonic custom config
|
||||
cfgSine := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
WaveformType: synth.WaveformSine,
|
||||
}
|
||||
cfgCustom := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
|
||||
}
|
||||
// Use fast-converging tau for test
|
||||
layerSine := synth.NewLayer(cfgSine, synth.SampleRate, 0.001)
|
||||
layerCustom := synth.NewLayer(cfgCustom, synth.SampleRate, 0.001)
|
||||
|
||||
// Set both to same target amplitude
|
||||
layerSine.UpdateTarget(1, 1)
|
||||
layerCustom.UpdateTarget(1, 1)
|
||||
|
||||
// Advance enough samples for EMA to converge (tau=0.001 at 44100 SR: ~44 samples to 63%)
|
||||
for i := 0; i < 200; i++ {
|
||||
layerSine.AdvanceSample()
|
||||
layerCustom.AdvanceSample()
|
||||
}
|
||||
|
||||
// Next 100 samples should match exactly
|
||||
for i := 0; i < 100; i++ {
|
||||
s1 := layerSine.AdvanceSample()
|
||||
s2 := layerCustom.AdvanceSample()
|
||||
if abs(s1-s2) > 1e-12 {
|
||||
t.Errorf("sample %d: sine preset (%.10f) != custom harmonic (%.10f), diff=%.2e", i, s1, s2, abs(s1-s2))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// abs returns the absolute value of x.
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
Reference in New Issue
Block a user