2026-10-18 15:39:01 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 15:39:01 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// emptyPATH sets PATH to a directory without a single binary: no espeak,
|
|
|
|
|
// no spd-say.
|
2026-10-18 15:39:01 +02:00
|
|
|
func emptyPATH(t *testing.T) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
t.Setenv("PATH", dir)
|
|
|
|
|
return dir
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 15:39:01 +02:00
|
|
|
func prependPATH(t *testing.T, dir string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestOSBuiltinName and TestNewRegistry pin the registry view.
|
2026-10-18 15:39:01 +02:00
|
|
|
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"} {
|
2026-10-18 16:34:43 +02:00
|
|
|
p, err := New(name, ChainConfig{})
|
2026-10-18 15:39:01 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("New(%q): %v", name, err)
|
|
|
|
|
}
|
|
|
|
|
if p.Name() != "osbuiltin" {
|
|
|
|
|
t.Errorf("New(%q).Name() = %q", name, p.Name())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// The cloud providers are in the registry.
|
2026-10-18 16:34:43 +02:00
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if _, err := New("doesnotexist", ChainConfig{}); err == nil {
|
|
|
|
|
t.Error("New(\"doesnotexist\") should fail")
|
2026-10-18 16:34:43 +02:00
|
|
|
} else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("New error should name the available providers: %v", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestAvailableWithoutTTS: empty PATH environment → Available false.
|
|
|
|
|
func TestAvailableWithoutTTS(t *testing.T) {
|
2026-10-18 15:39:01 +02:00
|
|
|
emptyPATH(t)
|
|
|
|
|
if (OSBuiltin{}).Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available() = true without any TTS binary, want false")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSynthesizeWithoutTTS: if byte-capable TTS is missing, the error must
|
|
|
|
|
// be ErrNoByteOutput (no panic, no other error type).
|
|
|
|
|
func TestSynthesizeWithoutTTS(t *testing.T) {
|
2026-10-18 15:39:01 +02:00
|
|
|
emptyPATH(t)
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 15:39:01 +02:00
|
|
|
if err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("Synthesize without TTS: no error")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
if !errors.Is(err, ErrNoByteOutput) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 15:39:01 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello world", "de")
|
2026-10-18 15:39:01 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Synthesize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if string(data) != string(minimalWAV) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Synthesize returned %d bytes, want the WAV bytes of the fake espeak (%d)", len(data), len(minimalWAV))
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
args, err := os.ReadFile(seen)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("fake espeak was not called: %v", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
if !(OSBuiltin{}).Available(context.Background()) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Available() = false despite espeak in PATH")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSynthesizeEmptyText is a clear error.
|
|
|
|
|
func TestSynthesizeEmptyText(t *testing.T) {
|
2026-10-18 15:39:01 +02:00
|
|
|
if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("Synthesize(\"\") should fail")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSynthesizeFailingEspeak: espeak crashes (exit 1) → clear error, not
|
|
|
|
|
// ErrNoByteOutput.
|
|
|
|
|
func TestSynthesizeFailingEspeak(t *testing.T) {
|
2026-10-18 15:39:01 +02:00
|
|
|
dir := emptyPATH(t)
|
|
|
|
|
fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`)
|
2026-10-24 11:15:35 +02:00
|
|
|
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
|
2026-10-18 15:39:01 +02:00
|
|
|
if err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatal("Synthesize with crashing espeak: no error")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
if errors.Is(err, ErrNoByteOutput) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("crashing espeak must not be ErrNoByteOutput: %v", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(err.Error(), "boom") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("error should contain the process stderr: %v", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSpeakDirectWithFakeSpdSay: SpeakDirect calls spd-say blocking (-w)
|
|
|
|
|
// with language (-l <lang>) and text.
|
|
|
|
|
func TestSpeakDirectWithFakeSpdSay(t *testing.T) {
|
2026-10-18 15:39:01 +02:00
|
|
|
dir := emptyPATH(t)
|
|
|
|
|
seen := filepath.Join(dir, "seen.txt")
|
|
|
|
|
fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
|
|
|
|
|
prependPATH(t, dir)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello world", "de"); err != nil {
|
2026-10-18 15:39:01 +02:00
|
|
|
t.Fatalf("SpeakDirect: %v", err)
|
|
|
|
|
}
|
|
|
|
|
args, err := os.ReadFile(seen)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("fake spd-say was not called: %v", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestSpeakDirectWithoutSpdSay is a clear error.
|
|
|
|
|
func TestSpeakDirectWithoutSpdSay(t *testing.T) {
|
2026-10-18 15:39:01 +02:00
|
|
|
emptyPATH(t)
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello", "de"); err == nil {
|
|
|
|
|
t.Error("SpeakDirect without spd-say should fail")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 15:39:01 +02:00
|
|
|
if os.Getenv("TALK_TEST_REAL_SPD") != "1" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Skip("skipped (audible); run with TALK_TEST_REAL_SPD=1")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
if _, err := exec.LookPath("spd-say"); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Skip("spd-say not installed")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Speech test via speech dispatcher", "de"); err != nil {
|
|
|
|
|
t.Fatalf("SpeakDirect against real spd-say: %v", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 15:39:01 +02:00
|
|
|
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,
|
2026-10-24 11:15:35 +02:00
|
|
|
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 samples
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|