From c9794cd4cd70b3f6bce393a98ba2d336816b35b2 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 12:02:06 +0100 Subject: [PATCH] feat(02-02): stereo mixer utilities with constant-power pan law - PanGains uses cos/sin constant-power pan law (D-11) - StereoFramesToInt16Bytes converts stereo frames to interleaved LE int16 bytes - clamp prevents int16 overflow for values outside [-1.0, 1.0] - All 7 mixer tests pass (pan law, byte conversion, clamping) --- synth/mixer.go | 40 +++++++++++++++++++ synth/mixer_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 synth/mixer.go create mode 100644 synth/mixer_test.go diff --git a/synth/mixer.go b/synth/mixer.go new file mode 100644 index 0000000..47ee8a0 --- /dev/null +++ b/synth/mixer.go @@ -0,0 +1,40 @@ +package synth + +import ( + "encoding/binary" + "math" +) + +// PanGains returns left and right channel gains for a pan position p in [-1, 1]. +// Uses constant-power (equal-power) pan law: cos/sin mapping. +// Per D-11: bass frequencies center, mid spread L/R, higher frequencies wider. +// At p=-1.0: gainL=1.0, gainR=0.0; at p=0.0: gainL=gainR=sqrt(2)/2; at p=1.0: gainL=0.0, gainR=1.0. +func PanGains(p float64) (gainL, gainR float64) { + angle := (p + 1.0) / 2.0 * math.Pi / 2.0 + return math.Cos(angle), math.Sin(angle) +} + +// StereoFramesToInt16Bytes converts [][2]float64 stereo frames to interleaved +// little-endian int16 bytes suitable for go-lame's Write method. +// Clamps values to [-1.0, 1.0] before conversion. +// Output format: [L0_lo, L0_hi, R0_lo, R0_hi, L1_lo, L1_hi, R1_lo, R1_hi, ...] +func StereoFramesToInt16Bytes(frames [][2]float64) []byte { + buf := make([]byte, len(frames)*4) // 2 channels * 2 bytes per sample + for i, frame := range frames { + l := clamp(frame[0]) + r := clamp(frame[1]) + binary.LittleEndian.PutUint16(buf[i*4:], uint16(int16(l*32767))) + binary.LittleEndian.PutUint16(buf[i*4+2:], uint16(int16(r*32767))) + } + return buf +} + +func clamp(v float64) float64 { + if v > 1.0 { + return 1.0 + } + if v < -1.0 { + return -1.0 + } + return v +} diff --git a/synth/mixer_test.go b/synth/mixer_test.go new file mode 100644 index 0000000..eaa8e5d --- /dev/null +++ b/synth/mixer_test.go @@ -0,0 +1,96 @@ +package synth + +import ( + "math" + "testing" +) + +func TestPanGainsCenter(t *testing.T) { + gainL, gainR := PanGains(0.0) + expected := math.Sqrt2 / 2 // cos(pi/4) = sin(pi/4) = 0.707... + if math.Abs(gainL-expected) > 1e-9 { + t.Errorf("PanGains(0.0) gainL = %v, want ~%v", gainL, expected) + } + if math.Abs(gainR-expected) > 1e-9 { + t.Errorf("PanGains(0.0) gainR = %v, want ~%v", gainR, expected) + } +} + +func TestPanGainsFullLeft(t *testing.T) { + gainL, gainR := PanGains(-1.0) + if math.Abs(gainL-1.0) > 1e-9 { + t.Errorf("PanGains(-1.0) gainL = %v, want ~1.0", gainL) + } + if math.Abs(gainR-0.0) > 1e-9 { + t.Errorf("PanGains(-1.0) gainR = %v, want ~0.0", gainR) + } +} + +func TestPanGainsFullRight(t *testing.T) { + gainL, gainR := PanGains(1.0) + if math.Abs(gainL-0.0) > 1e-9 { + t.Errorf("PanGains(1.0) gainL = %v, want ~0.0", gainL) + } + if math.Abs(gainR-1.0) > 1e-9 { + t.Errorf("PanGains(1.0) gainR = %v, want ~1.0", gainR) + } +} + +func TestPanGainsPowerPreserved(t *testing.T) { + pans := []float64{-1.0, -0.75, -0.5, -0.25, 0.0, 0.25, 0.5, 0.75, 1.0} + for _, p := range pans { + gainL, gainR := PanGains(p) + power := gainL*gainL + gainR*gainR + if math.Abs(power-1.0) > 1e-9 { + t.Errorf("PanGains(%v) power = %v, want 1.0 (constant power law)", p, power) + } + } +} + +func TestStereoFramesToInt16Bytes(t *testing.T) { + frames := [][2]float64{{1.0, -1.0}} + buf := StereoFramesToInt16Bytes(frames) + if len(buf) != 4 { + t.Fatalf("expected 4 bytes, got %d", len(buf)) + } + // int16(32767) in little-endian = [0xFF, 0x7F] + // int16(-32767) in little-endian = [0x01, 0x80] + expectedL := int16(32767) + expectedR := int16(-32767) + gotL := int16(uint16(buf[0]) | uint16(buf[1])<<8) + gotR := int16(uint16(buf[2]) | uint16(buf[3])<<8) + if gotL != expectedL { + t.Errorf("left channel = %d, want %d", gotL, expectedL) + } + if gotR != expectedR { + t.Errorf("right channel = %d, want %d", gotR, expectedR) + } +} + +func TestStereoFramesToInt16BytesZero(t *testing.T) { + frames := [][2]float64{{0.0, 0.0}} + buf := StereoFramesToInt16Bytes(frames) + if len(buf) != 4 { + t.Fatalf("expected 4 bytes, got %d", len(buf)) + } + for i, b := range buf { + if b != 0 { + t.Errorf("byte[%d] = %d, want 0", i, b) + } + } +} + +func TestClampPreventsOverflow(t *testing.T) { + // Values > 1.0 should be clamped to 1.0 before int16 conversion + frames := [][2]float64{{2.0, -2.0}} + buf := StereoFramesToInt16Bytes(frames) + gotL := int16(uint16(buf[0]) | uint16(buf[1])<<8) + gotR := int16(uint16(buf[2]) | uint16(buf[3])<<8) + // Should be clamped to max int16 positive / negative + if gotL != int16(32767) { + t.Errorf("clamped left = %d, want 32767", gotL) + } + if gotR != int16(-32767) { + t.Errorf("clamped right = %d, want -32767", gotR) + } +}