2026-10-18 15:39:09 +02:00
|
|
|
package audio
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"math"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// buildWAV builds a WAV file per the RIFF specification (implemented
|
|
|
|
|
// independently of the parser so parser and test fixture cannot share the
|
|
|
|
|
// same bug).
|
2026-10-18 15:39:09 +02:00
|
|
|
func buildWAV(audioFormat uint16, channels, rate, bits int, samples []byte) []byte {
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
buf.WriteString("RIFF")
|
|
|
|
|
payload := 28 + len(samples) // fmt (8+16) + data (8+n) + 4 bytes WAVE
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint32(payload))
|
|
|
|
|
buf.WriteString("WAVE")
|
|
|
|
|
|
|
|
|
|
buf.WriteString("fmt ")
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint32(16))
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, audioFormat)
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint16(channels))
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint32(rate))
|
|
|
|
|
blockAlign := channels * bits / 8
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint32(rate*blockAlign))
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint16(blockAlign))
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint16(bits))
|
|
|
|
|
|
|
|
|
|
buf.WriteString("data")
|
|
|
|
|
binary.Write(&buf, binary.LittleEndian, uint32(len(samples)))
|
|
|
|
|
buf.Write(samples)
|
|
|
|
|
return buf.Bytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDecodeWAV(t *testing.T) {
|
|
|
|
|
t.Run("16-bit mono", func(t *testing.T) {
|
|
|
|
|
wav := buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4})
|
|
|
|
|
f, pcm, err := decodeWAV(wav)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("decodeWAV: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if f.SampleRate != 22050 || f.Channels != 1 || f.BitsPerCoded != 16 || f.Float {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("format = %+v", f)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if string(pcm) != "\x01\x02\x03\x04" {
|
|
|
|
|
t.Errorf("PCM = %x", pcm)
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("8-bit stereo with extra chunk", func(t *testing.T) {
|
2026-10-18 15:39:09 +02:00
|
|
|
wav := buildWAV(1, 2, 8000, 8, []byte{7, 8, 9})
|
2026-10-24 11:15:35 +02:00
|
|
|
// insert a LIST chunk (word-aligned, odd length) between fmt and data
|
2026-10-18 15:39:09 +02:00
|
|
|
withList := append(wav[:36], append([]byte("LIST\x03\x00\x00\x00abc\x00"), wav[36:]...)...)
|
|
|
|
|
f, pcm, err := decodeWAV(withList)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("decodeWAV: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if f.BitsPerCoded != 8 || f.Channels != 2 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("format = %+v", f)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if string(pcm) != "\x07\x08\x09" {
|
|
|
|
|
t.Errorf("PCM = %x", pcm)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
t.Run("float32", func(t *testing.T) {
|
|
|
|
|
wav := buildWAV(3, 1, 44100, 32, []byte{0, 0, 0, 0})
|
|
|
|
|
f, _, err := decodeWAV(wav)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("decodeWAV: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !f.Float || f.BitsPerCoded != 32 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("format = %+v, want Float32", f)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
})
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("empty data chunk is allowed", func(t *testing.T) {
|
2026-10-18 15:39:09 +02:00
|
|
|
wav := buildWAV(1, 1, 22050, 16, nil)
|
|
|
|
|
_, pcm, err := decodeWAV(wav)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("decodeWAV: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(pcm) != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("PCM = %x, want empty", pcm)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
func TestDecodeWAVErrors(t *testing.T) {
|
2026-10-18 15:39:09 +02:00
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
data []byte
|
|
|
|
|
want string
|
|
|
|
|
}{
|
2026-10-24 11:15:35 +02:00
|
|
|
{"too short", []byte("RIFF"), "too short"},
|
|
|
|
|
{"no riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"},
|
|
|
|
|
{"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "unsupported format"},
|
|
|
|
|
{"3 channels", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "channels"},
|
|
|
|
|
{"without data", func() []byte {
|
2026-10-18 15:39:09 +02:00
|
|
|
wav := buildWAV(1, 1, 22050, 16, []byte{1, 2})
|
2026-10-24 11:15:35 +02:00
|
|
|
return wav[:36] // keep only the fmt chunk
|
2026-10-18 15:39:09 +02:00
|
|
|
}(), "data"},
|
2026-10-24 11:15:35 +02:00
|
|
|
{"broken chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "exceeds"},
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
_, _, err := decodeWAV(tc.data)
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("decodeWAV error = %v, want message containing %q", err, tc.want)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewSink(t *testing.T) {
|
|
|
|
|
for _, name := range []string{NameOto, NameNull} {
|
2026-10-18 15:46:20 +02:00
|
|
|
s, err := NewSink(name, nil)
|
2026-10-18 15:39:09 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewSink(%q): %v", name, err)
|
|
|
|
|
}
|
|
|
|
|
if s.Name() != name {
|
|
|
|
|
t.Errorf("NewSink(%q).Name() = %q", name, s.Name())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 15:46:20 +02:00
|
|
|
if _, err := NewSink("mpv", nil); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("NewSink(\"mpv\") should fail")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("null sink logs via the factory writer", func(t *testing.T) {
|
2026-10-18 15:46:20 +02:00
|
|
|
var buf bytes.Buffer
|
|
|
|
|
s, err := NewSink(NameNull, &buf)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewSink(%q): %v", NameNull, err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.Play(context.Background(), []byte("audio")); err != nil {
|
|
|
|
|
t.Fatalf("Play: %v", err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(buf.String(), "discarded (null sink)") {
|
|
|
|
|
t.Errorf("log = %q, want discard line via the factory writer", buf.String())
|
2026-10-18 15:46:20 +02:00
|
|
|
}
|
|
|
|
|
})
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
func TestNullSinkDiscardsAndLogs(t *testing.T) {
|
2026-10-18 15:39:09 +02:00
|
|
|
var buf bytes.Buffer
|
|
|
|
|
sink := NullSink{W: &buf}
|
|
|
|
|
if err := sink.Play(context.Background(), []byte("audio")); err != nil {
|
|
|
|
|
t.Fatalf("NullSink.Play: %v", err)
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(buf.String(), "discarded") {
|
|
|
|
|
t.Errorf("NullSink log = %q, want discard line", buf.String())
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if sink.Name() != NameNull {
|
|
|
|
|
t.Errorf("Name() = %q", sink.Name())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestOtoSinkInvalidData: OtoSink.Play must fail at WAV decoding before any
|
|
|
|
|
// audio device is touched; this is the CI/server-safe error path of the oto
|
|
|
|
|
// sink.
|
|
|
|
|
func TestOtoSinkInvalidData(t *testing.T) {
|
|
|
|
|
err := (OtoSink{}).Play(context.Background(), []byte("not audio"))
|
2026-10-18 15:39:09 +02:00
|
|
|
if err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("OtoSink.Play with garbage: no error")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "wav:") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("error should come from the WAV parser: %v", err)
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestOtoSinkRealPlayback runs only with TALK_TEST_REAL_AUDIO=1 (produces
|
|
|
|
|
// real sound); default test runs stay silent.
|
|
|
|
|
func TestOtoSinkRealPlayback(t *testing.T) {
|
2026-10-18 15:39:09 +02:00
|
|
|
if testing.Short() {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Skip("skipped (-short)")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
|
|
|
|
if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Skip("skipped (audible); run with TALK_TEST_REAL_AUDIO=1")
|
2026-10-18 15:39:09 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// 0.1 s 440 Hz sine, 16 bit mono 22050 Hz.
|
2026-10-18 15:39:09 +02:00
|
|
|
const rate = 22050
|
|
|
|
|
samples := make([]byte, rate/10*2)
|
|
|
|
|
for i := 0; i < rate/10; i++ {
|
|
|
|
|
v := int16(8000 * math.Sin(float64(i)/float64(rate)*2*math.Pi*440))
|
|
|
|
|
binary.LittleEndian.PutUint16(samples[i*2:], uint16(v))
|
|
|
|
|
}
|
|
|
|
|
if err := (OtoSink{}).Play(context.Background(), buildWAV(1, 1, rate, 16, samples)); err != nil {
|
|
|
|
|
t.Fatalf("OtoSink.Play: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|