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)
This commit is contained in:
2026-03-26 12:02:06 +01:00
parent d7117a7e41
commit c9794cd4cd
2 changed files with 136 additions and 0 deletions
+40
View File
@@ -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
}
+96
View File
@@ -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)
}
}