package audio import ( "bytes" "context" "encoding/binary" "math" "os" "strings" "testing" ) // buildWAV builds a WAV file per the RIFF specification (implemented // independently of the parser so parser and test fixture cannot share the // same bug). 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 { t.Errorf("format = %+v", f) } if string(pcm) != "\x01\x02\x03\x04" { t.Errorf("PCM = %x", pcm) } }) t.Run("8-bit stereo with extra chunk", func(t *testing.T) { wav := buildWAV(1, 2, 8000, 8, []byte{7, 8, 9}) // insert a LIST chunk (word-aligned, odd length) between fmt and data 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 { t.Errorf("format = %+v", f) } 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 { t.Errorf("format = %+v, want Float32", f) } }) t.Run("empty data chunk is allowed", func(t *testing.T) { wav := buildWAV(1, 1, 22050, 16, nil) _, pcm, err := decodeWAV(wav) if err != nil { t.Fatalf("decodeWAV: %v", err) } if len(pcm) != 0 { t.Errorf("PCM = %x, want empty", pcm) } }) } func TestDecodeWAVErrors(t *testing.T) { cases := []struct { name string data []byte want string }{ {"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 { wav := buildWAV(1, 1, 22050, 16, []byte{1, 2}) return wav[:36] // keep only the fmt chunk }(), "data"}, {"broken chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "exceeds"}, } 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) { t.Errorf("decodeWAV error = %v, want message containing %q", err, tc.want) } }) } } func TestNewSink(t *testing.T) { for _, name := range []string{NameOto, NameNull} { s, err := NewSink(name, nil) if err != nil { t.Fatalf("NewSink(%q): %v", name, err) } if s.Name() != name { t.Errorf("NewSink(%q).Name() = %q", name, s.Name()) } } if _, err := NewSink("mpv", nil); err == nil { t.Error("NewSink(\"mpv\") should fail") } t.Run("null sink logs via the factory writer", func(t *testing.T) { 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) } if !strings.Contains(buf.String(), "discarded (null sink)") { t.Errorf("log = %q, want discard line via the factory writer", buf.String()) } }) } func TestNullSinkDiscardsAndLogs(t *testing.T) { var buf bytes.Buffer sink := NullSink{W: &buf} if err := sink.Play(context.Background(), []byte("audio")); err != nil { t.Fatalf("NullSink.Play: %v", err) } if !strings.Contains(buf.String(), "discarded") { t.Errorf("NullSink log = %q, want discard line", buf.String()) } if sink.Name() != NameNull { t.Errorf("Name() = %q", sink.Name()) } } // 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")) if err == nil { t.Fatal("OtoSink.Play with garbage: no error") } if !strings.Contains(err.Error(), "wav:") { t.Errorf("error should come from the WAV parser: %v", err) } } // TestOtoSinkRealPlayback runs only with TALK_TEST_REAL_AUDIO=1 (produces // real sound); default test runs stay silent. func TestOtoSinkRealPlayback(t *testing.T) { if testing.Short() { t.Skip("skipped (-short)") } if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" { t.Skip("skipped (audible); run with TALK_TEST_REAL_AUDIO=1") } // 0.1 s 440 Hz sine, 16 bit mono 22050 Hz. 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) } }