Merge branch 'worktree-agent-a56cb818'
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
---
|
||||
phase: 02-audio-synthesis-engine
|
||||
plan: "01"
|
||||
subsystem: synth
|
||||
tags: [synthesis, oscillator, EMA, harmonics, audio, go-lame, CGo]
|
||||
dependency_graph:
|
||||
requires: [classify/types.go]
|
||||
provides: [synth/config.go, synth/oscillator.go, synth/layer.go]
|
||||
affects: [synth/mixer.go (Plan 02-02)]
|
||||
tech_stack:
|
||||
added: [github.com/sjzar/go-lame@v0.0.9, gcc, ffprobe]
|
||||
patterns: [phase-accumulator oscillator, EMA amplitude smoothing, additive synthesis, whisper floor]
|
||||
key_files:
|
||||
created:
|
||||
- synth/config.go
|
||||
- synth/oscillator.go
|
||||
- synth/layer.go
|
||||
- synth/config_test.go
|
||||
- synth/oscillator_test.go
|
||||
- synth/layer_test.go
|
||||
modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
decisions:
|
||||
- "go-lame v0.0.9 added as indirect dependency (no direct usage yet); will be promoted to direct in Plan 02-02 when encoder is wired"
|
||||
- "go-audio/wav intentionally excluded — writing interleaved int16 PCM bytes directly to LameWriter is simpler (per CONTEXT.md Claude's Discretion grant)"
|
||||
- "ClassNTP has only 2 harmonics (not 3) — NTP is a minimal timing signal; 2 harmonics satisfy test requirement and match timbral intent"
|
||||
metrics:
|
||||
duration: "~15 minutes"
|
||||
completed_date: "2026-03-26"
|
||||
tasks_completed: 2
|
||||
files_created: 6
|
||||
files_modified: 2
|
||||
---
|
||||
|
||||
# Phase 02 Plan 01: Synth Foundation — Config, Oscillator, and Layer Summary
|
||||
|
||||
**One-liner:** Phase-accumulator additive oscillator with EMA amplitude smoothing and whisper floor for 11 traffic-class drone layers, tested via TDD.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### synth/config.go
|
||||
Defines the frequency/harmonic/pan configuration table for all 11 traffic classes. Key constants: `SampleRate=44100`, `WhisperFloor=0.03`, `GainPerLayer=1/11`. Each class has a `FreqConfig` with `BaseHz` in 60-800 Hz, 2-3 `HarmonicDef` entries (ratio + amplitude), and a stereo pan position in [-1, 1].
|
||||
|
||||
Frequency assignments follow musical intervals (D-02/D-03): ICMP=65 Hz (deep bass), DNS=110 Hz, HTTPS=175 Hz, HTTP=220 Hz, SSH=330 Hz, SMTP=440 Hz, NTP=520 Hz, DHCP=600 Hz, other-TCP=700 Hz, other-UDP=780 Hz. ClassUnknown=437 Hz (detuned 3 Hz below SMTP for perceptible beating, per D-04).
|
||||
|
||||
### synth/oscillator.go
|
||||
Phase-accumulator oscillator. `Advance(harmonics []HarmonicDef) float64` sums weighted sine partials and normalizes by total weight. Phase advances by `freq/sampleRate` per sample with subtraction wrap (`o.phase -= 1.0`), not `math.Mod`, for performance.
|
||||
|
||||
### synth/layer.go
|
||||
`Layer` wraps an oscillator with EMA amplitude dynamics. `UpdateTarget(count, maxCount)` sets target amplitude: unseen layers stay at 0.0; seen layers floor at `WhisperFloor` and scale linearly to 1.0 at max count. `AdvanceSample()` advances the oscillator and the EMA (`currentAmp += alpha * (target - current)`). `EMAAlpha(tau, sr)` computes the per-sample coefficient for a given time constant in seconds.
|
||||
|
||||
### Tests (12 passing)
|
||||
- Config: `TestAllClassesHaveConfig`, `TestFrequenciesInRange`, `TestFrequenciesUnique`, `TestHarmonicsNonEmpty`, `TestPanPositionsInRange`
|
||||
- Oscillator: `TestOscillatorAdvance`, `TestOscillatorPhaseWrap`, `TestOscillatorDistinctFreqs`
|
||||
- Layer: `TestEMAAmplitudeRise`, `TestEMAAmplitudeDecay`, `TestWhisperFloor`, `TestWhisperFloorNotSeenIsZero`
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Description |
|
||||
|------|--------|-------------|
|
||||
| 1 — environment setup | dc7aed2 | chore(02-01): install gcc, ffprobe, and go-lame dependency |
|
||||
| 2 — tests (RED) | bb13527 | test(02-01): add failing tests for synth config, oscillator, and layer |
|
||||
| 2 — implementation (GREEN) | 1aaa15d | feat(02-01): implement synth config, oscillator, and layer |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all config entries are fully wired with real frequency/harmonic/pan values. No placeholder data.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- synth/config.go: FOUND
|
||||
- synth/oscillator.go: FOUND
|
||||
- synth/layer.go: FOUND
|
||||
- synth/config_test.go: FOUND
|
||||
- synth/oscillator_test.go: FOUND
|
||||
- synth/layer_test.go: FOUND
|
||||
- Commit dc7aed2: FOUND
|
||||
- Commit bb13527: FOUND
|
||||
- Commit 1aaa15d: FOUND
|
||||
- `go test ./synth/...`: 12 tests PASS
|
||||
- `CGO_ENABLED=1 go build ./...`: PASS
|
||||
- go.mod contains go-lame v0.0.9: VERIFIED
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sjzar/go-lame v0.0.9 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
|
||||
@@ -13,6 +13,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sjzar/go-lame v0.0.9 h1:x/X3I+uKVsx8WeKO5cu4fvkAbm2okOaP1ibeeI3urAU=
|
||||
github.com/sjzar/go-lame v0.0.9/go.mod h1:8RmqWcAKSbBAk6bTRV9d8mdDxqK3hY9vFyoJ4DoQE6Y=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package synth
|
||||
|
||||
import "github.com/netsynth/netsynth/classify"
|
||||
|
||||
const (
|
||||
SampleRate = 44100 // D-13: CD quality
|
||||
WindowMs = 500 // matches aggregate.DefaultWindowMs
|
||||
SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050
|
||||
NumLayers = 11
|
||||
GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0909
|
||||
WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude
|
||||
)
|
||||
|
||||
// HarmonicDef defines one partial in an additive synthesizer.
|
||||
type HarmonicDef struct {
|
||||
Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc.
|
||||
Amplitude float64 // relative weight
|
||||
}
|
||||
|
||||
// FreqConfig holds synthesis parameters for one traffic class.
|
||||
type FreqConfig struct {
|
||||
BaseHz float64
|
||||
Harmonics []HarmonicDef
|
||||
Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right
|
||||
}
|
||||
|
||||
// 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},
|
||||
// D-04: 437 Hz is ~12 cents flat from A4 (440 Hz/SMTP).
|
||||
// Creates 3 Hz beating when SMTP is present = dissonant "doesn't belong" signal.
|
||||
classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0},
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package synth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netsynth/netsynth/classify"
|
||||
"github.com/netsynth/netsynth/synth"
|
||||
)
|
||||
|
||||
func TestAllClassesHaveConfig(t *testing.T) {
|
||||
for _, class := range classify.AllClasses() {
|
||||
if _, ok := synth.ClassFreqConfigs[class]; !ok {
|
||||
t.Errorf("class %q has no entry in ClassFreqConfigs", class)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrequenciesInRange(t *testing.T) {
|
||||
for class, cfg := range synth.ClassFreqConfigs {
|
||||
if cfg.BaseHz < 60 || cfg.BaseHz > 800 {
|
||||
t.Errorf("class %q BaseHz=%.1f is out of range [60, 800]", class, cfg.BaseHz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrequenciesUnique(t *testing.T) {
|
||||
seen := make(map[float64]classify.TrafficClass)
|
||||
for class, cfg := range synth.ClassFreqConfigs {
|
||||
if prev, exists := seen[cfg.BaseHz]; exists {
|
||||
t.Errorf("classes %q and %q share the same BaseHz=%.1f", prev, class, cfg.BaseHz)
|
||||
}
|
||||
seen[cfg.BaseHz] = class
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarmonicsNonEmpty(t *testing.T) {
|
||||
for class, cfg := range synth.ClassFreqConfigs {
|
||||
if len(cfg.Harmonics) < 2 {
|
||||
t.Errorf("class %q has fewer than 2 harmonics (got %d)", class, len(cfg.Harmonics))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanPositionsInRange(t *testing.T) {
|
||||
for class, cfg := range synth.ClassFreqConfigs {
|
||||
if cfg.Pan < -1.0 || cfg.Pan > 1.0 {
|
||||
t.Errorf("class %q Pan=%.2f is out of range [-1.0, 1.0]", class, cfg.Pan)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package synth
|
||||
|
||||
import "math"
|
||||
|
||||
// EMAAlpha computes the per-sample smoothing coefficient for a given time constant.
|
||||
// tau=1.0 at SR=44100 gives alpha ~0.0000227 (63% of target reached in 1 second). Per D-07.
|
||||
func EMAAlpha(tau float64, sampleRate int) float64 {
|
||||
return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate)))
|
||||
}
|
||||
|
||||
// Layer combines an oscillator with EMA amplitude smoothing for one traffic class.
|
||||
type Layer struct {
|
||||
Config FreqConfig
|
||||
Osc *Oscillator
|
||||
currentAmp float64
|
||||
targetAmp float64
|
||||
alpha float64 // EMA coefficient
|
||||
seen bool // whether this class has ever had count > 0
|
||||
whisper float64 // whisper floor amplitude (D-08)
|
||||
}
|
||||
|
||||
// NewLayer creates a Layer for the given config using the specified sample rate and EMA time constant (tau in seconds).
|
||||
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
|
||||
return &Layer{
|
||||
Config: cfg,
|
||||
Osc: NewOscillator(cfg.BaseHz, sampleRate),
|
||||
alpha: EMAAlpha(tau, sampleRate),
|
||||
whisper: WhisperFloor,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTarget sets the target amplitude from a packet count and max count across all classes.
|
||||
// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate.
|
||||
func (l *Layer) UpdateTarget(count int64, maxCount int64) {
|
||||
if count > 0 {
|
||||
l.seen = true
|
||||
}
|
||||
if !l.seen {
|
||||
l.targetAmp = 0.0
|
||||
return
|
||||
}
|
||||
normalizedRate := 0.0
|
||||
if maxCount > 0 {
|
||||
normalizedRate = float64(count) / float64(maxCount)
|
||||
}
|
||||
l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate
|
||||
}
|
||||
|
||||
// AdvanceSample renders one sample and advances the EMA amplitude toward target.
|
||||
// Returns the raw mono sample (before pan/gain).
|
||||
func (l *Layer) AdvanceSample() float64 {
|
||||
sample := l.Osc.Advance(l.Config.Harmonics)
|
||||
l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp)
|
||||
return sample * l.currentAmp
|
||||
}
|
||||
|
||||
// CurrentAmp returns the current amplitude (for testing).
|
||||
func (l *Layer) CurrentAmp() float64 {
|
||||
return l.currentAmp
|
||||
}
|
||||
|
||||
// TargetAmp returns the target amplitude (for testing).
|
||||
func (l *Layer) TargetAmp() float64 {
|
||||
return l.targetAmp
|
||||
}
|
||||
|
||||
// Seen returns whether this layer has ever received traffic (for testing).
|
||||
func (l *Layer) Seen() bool {
|
||||
return l.seen
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package synth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netsynth/netsynth/synth"
|
||||
)
|
||||
|
||||
func TestEMAAmplitudeRise(t *testing.T) {
|
||||
cfg2 := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
|
||||
Pan: 0.0,
|
||||
}
|
||||
layer := synth.NewLayer(cfg2, synth.SampleRate, 1.0)
|
||||
|
||||
// Force the layer to have been seen and set target to 1.0
|
||||
layer.UpdateTarget(100, 100)
|
||||
|
||||
// After 1 second (SampleRate samples) with tau=1.0, currentAmp should be > 0.5
|
||||
// EMA: after tau seconds, amplitude reaches ~63% of target
|
||||
for i := 0; i < synth.SampleRate; i++ {
|
||||
layer.AdvanceSample()
|
||||
}
|
||||
if layer.CurrentAmp() <= 0.5 {
|
||||
t.Errorf("expected currentAmp > 0.5 after 1 second rise, got %.4f", layer.CurrentAmp())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEMAAmplitudeDecay(t *testing.T) {
|
||||
cfg := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
|
||||
Pan: 0.0,
|
||||
}
|
||||
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
|
||||
|
||||
// Set up: layer has been seen (currentAmp starts at 1.0) and target is whisper floor
|
||||
// We'll manually prime by updating target with 100/100 first then re-route to whisper
|
||||
layer.UpdateTarget(100, 100) // mark as seen, target=1.0
|
||||
// Force currentAmp to 1.0 by running a few cycles at target 1.0
|
||||
for i := 0; i < synth.SampleRate*3; i++ {
|
||||
layer.AdvanceSample()
|
||||
}
|
||||
// Now decay: set count=0 (whisper floor kicks in)
|
||||
layer.UpdateTarget(0, 100)
|
||||
// After 1 second, currentAmp should be < 0.5
|
||||
for i := 0; i < synth.SampleRate; i++ {
|
||||
layer.AdvanceSample()
|
||||
}
|
||||
if layer.CurrentAmp() >= 0.5 {
|
||||
t.Errorf("expected currentAmp < 0.5 after 1 second decay, got %.4f", layer.CurrentAmp())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhisperFloor(t *testing.T) {
|
||||
cfg := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
|
||||
Pan: 0.0,
|
||||
}
|
||||
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
|
||||
|
||||
// Mark layer as seen by passing count=1, then set count=0
|
||||
layer.UpdateTarget(1, 100)
|
||||
layer.UpdateTarget(0, 100)
|
||||
|
||||
// Target should be whisper floor (not zero) because seen=true
|
||||
if layer.TargetAmp() < synth.WhisperFloor {
|
||||
t.Errorf("expected targetAmp >= WhisperFloor (%.2f) for seen layer at zero count, got %.4f",
|
||||
synth.WhisperFloor, layer.TargetAmp())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhisperFloorNotSeenIsZero(t *testing.T) {
|
||||
cfg := synth.FreqConfig{
|
||||
BaseHz: 440.0,
|
||||
Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}},
|
||||
Pan: 0.0,
|
||||
}
|
||||
layer := synth.NewLayer(cfg, synth.SampleRate, 1.0)
|
||||
|
||||
// Never seen — UpdateTarget with zero count
|
||||
layer.UpdateTarget(0, 100)
|
||||
if layer.TargetAmp() != 0.0 {
|
||||
t.Errorf("expected targetAmp == 0.0 for unseen layer, got %.4f", layer.TargetAmp())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package synth
|
||||
|
||||
import "math"
|
||||
|
||||
// Oscillator is a phase-accumulator oscillator that generates additive sine waveforms.
|
||||
type Oscillator struct {
|
||||
phase float64
|
||||
freq float64
|
||||
sr float64
|
||||
}
|
||||
|
||||
// NewOscillator creates an oscillator at the given frequency and sample rate.
|
||||
func NewOscillator(freq float64, sampleRate int) *Oscillator {
|
||||
return &Oscillator{freq: freq, sr: float64(sampleRate)}
|
||||
}
|
||||
|
||||
// Advance returns one sample: fundamental + harmonics summed and normalized to [-1, 1].
|
||||
func (o *Oscillator) Advance(harmonics []HarmonicDef) float64 {
|
||||
sum := 0.0
|
||||
totalWeight := 0.0
|
||||
for _, h := range harmonics {
|
||||
sum += h.Amplitude * math.Sin(2*math.Pi*o.phase*float64(h.Ratio))
|
||||
totalWeight += h.Amplitude
|
||||
}
|
||||
o.phase += o.freq / o.sr
|
||||
if o.phase >= 1.0 {
|
||||
o.phase -= 1.0
|
||||
}
|
||||
if totalWeight > 0 {
|
||||
return sum / totalWeight
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Phase returns the current phase (for testing).
|
||||
func (o *Oscillator) Phase() float64 {
|
||||
return o.phase
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package synth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netsynth/netsynth/synth"
|
||||
)
|
||||
|
||||
func TestOscillatorAdvance(t *testing.T) {
|
||||
harmonics := []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.5}}
|
||||
osc := synth.NewOscillator(440.0, synth.SampleRate)
|
||||
|
||||
hasPos := false
|
||||
hasNeg := false
|
||||
for i := 0; i < 100; i++ {
|
||||
s := osc.Advance(harmonics)
|
||||
if s > 0 {
|
||||
hasPos = true
|
||||
}
|
||||
if s < 0 {
|
||||
hasNeg = true
|
||||
}
|
||||
}
|
||||
if !hasPos || !hasNeg {
|
||||
t.Errorf("expected both positive and negative samples; hasPos=%v hasNeg=%v", hasPos, hasNeg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOscillatorPhaseWrap(t *testing.T) {
|
||||
harmonics := []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
|
||||
osc := synth.NewOscillator(440.0, synth.SampleRate)
|
||||
|
||||
for i := 0; i < synth.SampleRate; i++ {
|
||||
osc.Advance(harmonics)
|
||||
}
|
||||
p := osc.Phase()
|
||||
if p < 0 || p >= 1.0 {
|
||||
t.Errorf("expected phase in [0, 1), got %.6f", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOscillatorDistinctFreqs(t *testing.T) {
|
||||
harmonics := []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
|
||||
osc65 := synth.NewOscillator(65.0, synth.SampleRate)
|
||||
osc440 := synth.NewOscillator(440.0, synth.SampleRate)
|
||||
|
||||
same := true
|
||||
for i := 0; i < 100; i++ {
|
||||
s65 := osc65.Advance(harmonics)
|
||||
s440 := osc440.Advance(harmonics)
|
||||
if s65 != s440 {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Error("expected oscillators at 65 Hz and 440 Hz to produce different sample sequences")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user