talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
191 lines
5.8 KiB
Go
191 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|