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>
213 lines
6.8 KiB
Go
213 lines
6.8 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeBin writes an executable shell script called name into dir that runs
|
|
// body. This fakes the provider's external processes at the real process
|
|
// boundary (PATH manipulation, no mock framework).
|
|
func fakeBin(t *testing.T, dir, name, body string) {
|
|
t.Helper()
|
|
script := "#!/bin/sh\n" + body + "\n"
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// emptyPATH sets PATH to a directory without a single binary: no espeak,
|
|
// no spd-say.
|
|
func emptyPATH(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("PATH", dir)
|
|
return dir
|
|
}
|
|
|
|
// prependPATH puts dir in front of the existing PATH: binaries in it shadow
|
|
// system binaries of the same name (fake espeak before a real one) while
|
|
// helpers such as cat stay reachable.
|
|
func prependPATH(t *testing.T, dir string) {
|
|
t.Helper()
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
}
|
|
|
|
// TestOSBuiltinName and TestNewRegistry pin the registry view.
|
|
func TestOSBuiltinName(t *testing.T) {
|
|
if got := (OSBuiltin{}).Name(); got != "osbuiltin" {
|
|
t.Errorf("Name() = %q, want %q", got, "osbuiltin")
|
|
}
|
|
}
|
|
|
|
func TestNewRegistry(t *testing.T) {
|
|
for _, name := range []string{"", "osbuiltin"} {
|
|
p, err := New(name, ChainConfig{})
|
|
if err != nil {
|
|
t.Fatalf("New(%q): %v", name, err)
|
|
}
|
|
if p.Name() != "osbuiltin" {
|
|
t.Errorf("New(%q).Name() = %q", name, p.Name())
|
|
}
|
|
}
|
|
// The cloud providers are in the registry.
|
|
for _, name := range []string{"elevenlabs", "openai", "edge"} {
|
|
p, err := New(name, ChainConfig{})
|
|
if err != nil {
|
|
t.Fatalf("New(%q): %v", name, err)
|
|
}
|
|
if p.Name() != name {
|
|
t.Errorf("New(%q).Name() = %q", name, p.Name())
|
|
}
|
|
}
|
|
if _, err := New("doesnotexist", ChainConfig{}); err == nil {
|
|
t.Error("New(\"doesnotexist\") should fail")
|
|
} else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") {
|
|
t.Errorf("New error should name the available providers: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAvailableWithoutTTS: empty PATH environment → Available false.
|
|
func TestAvailableWithoutTTS(t *testing.T) {
|
|
emptyPATH(t)
|
|
if (OSBuiltin{}).Available(context.Background()) {
|
|
t.Error("Available() = true without any TTS binary, want false")
|
|
}
|
|
}
|
|
|
|
// TestSynthesizeWithoutTTS: if byte-capable TTS is missing, the error must
|
|
// be ErrNoByteOutput (no panic, no other error type).
|
|
func TestSynthesizeWithoutTTS(t *testing.T) {
|
|
emptyPATH(t)
|
|
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
|
|
if err == nil {
|
|
t.Fatal("Synthesize without TTS: no error")
|
|
}
|
|
if !errors.Is(err, ErrNoByteOutput) {
|
|
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
|
|
}
|
|
}
|
|
|
|
// TestSynthesizeWithFakeEspeak: a faked espeak returns WAV bytes on stdout;
|
|
// Synthesize must pass exactly these bytes through and must have received
|
|
// the voice (-v <lang>) and the text.
|
|
func TestSynthesizeWithFakeEspeak(t *testing.T) {
|
|
dir := t.TempDir()
|
|
wav := filepath.Join(dir, "probe.wav")
|
|
if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := filepath.Join(dir, "seen.txt")
|
|
fakeBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`"
|
|
cat "`+wav+`"`)
|
|
prependPATH(t, dir)
|
|
|
|
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello world", "de")
|
|
if err != nil {
|
|
t.Fatalf("Synthesize: %v", err)
|
|
}
|
|
if string(data) != string(minimalWAV) {
|
|
t.Errorf("Synthesize returned %d bytes, want the WAV bytes of the fake espeak (%d)", len(data), len(minimalWAV))
|
|
}
|
|
args, err := os.ReadFile(seen)
|
|
if err != nil {
|
|
t.Fatalf("fake espeak was not called: %v", err)
|
|
}
|
|
if got := string(args); got != "-v de --stdout Hello world\n" {
|
|
t.Errorf("fake espeak ARGS = %q, want %q", got, "-v de --stdout Hello world\n")
|
|
}
|
|
if !(OSBuiltin{}).Available(context.Background()) {
|
|
t.Error("Available() = false despite espeak in PATH")
|
|
}
|
|
}
|
|
|
|
// TestSynthesizeEmptyText is a clear error.
|
|
func TestSynthesizeEmptyText(t *testing.T) {
|
|
if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil {
|
|
t.Error("Synthesize(\"\") should fail")
|
|
}
|
|
}
|
|
|
|
// TestSynthesizeFailingEspeak: espeak crashes (exit 1) → clear error, not
|
|
// ErrNoByteOutput.
|
|
func TestSynthesizeFailingEspeak(t *testing.T) {
|
|
dir := emptyPATH(t)
|
|
fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`)
|
|
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
|
|
if err == nil {
|
|
t.Fatal("Synthesize with crashing espeak: no error")
|
|
}
|
|
if errors.Is(err, ErrNoByteOutput) {
|
|
t.Errorf("crashing espeak must not be ErrNoByteOutput: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "boom") {
|
|
t.Errorf("error should contain the process stderr: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSpeakDirectWithFakeSpdSay: SpeakDirect calls spd-say blocking (-w)
|
|
// with language (-l <lang>) and text.
|
|
func TestSpeakDirectWithFakeSpdSay(t *testing.T) {
|
|
dir := emptyPATH(t)
|
|
seen := filepath.Join(dir, "seen.txt")
|
|
fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
|
prependPATH(t, dir)
|
|
|
|
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello world", "de"); err != nil {
|
|
t.Fatalf("SpeakDirect: %v", err)
|
|
}
|
|
args, err := os.ReadFile(seen)
|
|
if err != nil {
|
|
t.Fatalf("fake spd-say was not called: %v", err)
|
|
}
|
|
if got := string(args); got != "-w -l de Hello world\n" {
|
|
t.Errorf("fake spd-say ARGS = %q, want %q", got, "-w -l de Hello world\n")
|
|
}
|
|
}
|
|
|
|
// TestSpeakDirectWithoutSpdSay is a clear error.
|
|
func TestSpeakDirectWithoutSpdSay(t *testing.T) {
|
|
emptyPATH(t)
|
|
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello", "de"); err == nil {
|
|
t.Error("SpeakDirect without spd-say should fail")
|
|
}
|
|
}
|
|
|
|
// TestSpeakDirectRealSpdSay runs only when TALK_TEST_REAL_SPD=1 is set (it
|
|
// produces real, audible output through speech-dispatcher; one-off manual
|
|
// acceptance, standard test runs stay silent).
|
|
func TestSpeakDirectRealSpdSay(t *testing.T) {
|
|
if os.Getenv("TALK_TEST_REAL_SPD") != "1" {
|
|
t.Skip("skipped (audible); run with TALK_TEST_REAL_SPD=1")
|
|
}
|
|
if _, err := exec.LookPath("spd-say"); err != nil {
|
|
t.Skip("spd-say not installed")
|
|
}
|
|
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Speech test via speech dispatcher", "de"); err != nil {
|
|
t.Fatalf("SpeakDirect against real spd-say: %v", err)
|
|
}
|
|
}
|
|
|
|
// minimalWAV is a minimal valid RIFF/WAVE file (PCM 16 bit, mono, 8000 Hz)
|
|
// with four samples, hand-built per the RIFF specification, independent of
|
|
// the parser in the audio package.
|
|
var minimalWAV = []byte{
|
|
'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8)
|
|
'W', 'A', 'V', 'E',
|
|
'f', 'm', 't', ' ', 16, 0, 0, 0,
|
|
1, 0, // PCM
|
|
1, 0, // mono
|
|
0x40, 0x1F, 0, 0, // 8000 Hz
|
|
0x80, 0x3E, 0, 0, // ByteRate = 16000
|
|
2, 0, // BlockAlign
|
|
16, 0, // Bits
|
|
'd', 'a', 't', 'a', 8, 0, 0, 0,
|
|
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 samples
|
|
}
|