feat(provider): Provider-Vertrag und osbuiltin für OS-eingebautes TTS
Interface Name/Available/Synthesize (Contract für die Kette aus Task 03) plus dokumentierte optionale Erweiterung DirectSpeaker für Plattformen, deren OS-TTS keine Audio-Bytes liefert: spd-say (speech-dispatcher) kann nach --help-Verifikation nur selbst abspielen — dort spricht SpeakDirect blockierend (spd-say -w -l <lang>). Synthesize liefert RIFF/WAVE: Linux espeak/espeak-ng (--stdout), macOS say (temporäre WAV-Datei, LEI16@22050), Windows SAPI (PowerShell, -EncodedCommand). Tests mit Fake-Prozessen über PATH-Manipulation; echte spd-say-Ausgabe nur hinter TALK_TEST_REAL_SPD=1.
This commit is contained in:
1 parent
36ad1dfa4e
commit
5f450ea015
4 files changed
+518
-5
No files matched your search
@@ -0,0 +1,202 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBin schreibt ein ausführbares Shell-Skript namens name in dir, das
|
||||
// body ausführt. Damit werden die externen Prozesse des Providers an der
|
||||
// echten Prozessgrenze gefaked (PATH-Manipulation, kein 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 setzt PATH auf ein Verzeichnis ohne einzige Binärdatei —
|
||||
// kein espeak, kein spd-say.
|
||||
func emptyPATH(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
// prependPATH stellt dir vor den bestehenden PATH: Binaries darin
|
||||
// überschatten System-Binaries gleichen Namens (fake espeak vor einem
|
||||
// echten), während Hilfsprogramme wie cat weiterhin erreichbar bleiben.
|
||||
func prependPATH(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
|
||||
// TestOSBuiltinName und TestNewRegistry pinnen die Registry-Sicht.
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%q): %v", name, err)
|
||||
}
|
||||
if p.Name() != "osbuiltin" {
|
||||
t.Errorf("New(%q).Name() = %q", name, p.Name())
|
||||
}
|
||||
}
|
||||
if _, err := New("elevenlabs"); err == nil {
|
||||
t.Error("New(\"elevenlabs\") soll in diesem Stand fehlschlagen (Task 03 ergänzt)")
|
||||
} else if !strings.Contains(err.Error(), "osbuiltin") {
|
||||
t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAvailableOhneTTS: leere PATH-Umgebung → Available false.
|
||||
func TestAvailableOhneTTS(t *testing.T) {
|
||||
emptyPATH(t)
|
||||
if (OSBuiltin{}).Available(context.Background()) {
|
||||
t.Error("Available() = true ohne jedes TTS-Binary, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthesizeOhneTTS: fehlt byte-fähiges TTS, muss der Fehler
|
||||
// ErrNoByteOutput sein (kein Panik, kein anderer Fehlertyp).
|
||||
func TestSynthesizeOhneTTS(t *testing.T) {
|
||||
emptyPATH(t)
|
||||
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
|
||||
if err == nil {
|
||||
t.Fatal("Synthesize ohne TTS: kein Fehler")
|
||||
}
|
||||
if !errors.Is(err, ErrNoByteOutput) {
|
||||
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthesizeMitFakeEspeak: ein gefaktes espeak liefert WAV-Bytes auf
|
||||
// stdout; Synthesize muss genau diese Bytes durchreichen und die Stimme
|
||||
// (-v <lang>) sowie den Text übergeben bekommen haben.
|
||||
func TestSynthesizeMitFakeEspeak(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(), "Hallo Welt", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("Synthesize: %v", err)
|
||||
}
|
||||
if string(data) != string(minimalWAV) {
|
||||
t.Errorf("Synthesize lieferte %d Bytes, want die WAV-Bytes des Fake-espeak (%d)", len(data), len(minimalWAV))
|
||||
}
|
||||
args, err := os.ReadFile(seen)
|
||||
if err != nil {
|
||||
t.Fatalf("Fake-espeak wurde nicht aufgerufen: %v", err)
|
||||
}
|
||||
if got := string(args); got != "-v de --stdout Hallo Welt\n" {
|
||||
t.Errorf("Fake-espeak ARGS = %q, want %q", got, "-v de --stdout Hallo Welt\n")
|
||||
}
|
||||
if !(OSBuiltin{}).Available(context.Background()) {
|
||||
t.Error("Available() = false trotz espeak im PATH")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthesizeLeererText ist ein klarer Fehler.
|
||||
func TestSynthesizeLeererText(t *testing.T) {
|
||||
if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil {
|
||||
t.Error("Synthesize(\"\") soll fehlschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthesizeFehlgeschlagenesEspeak: espeak crasht (exit 1) → klarer
|
||||
// Fehler, kein ErrNoByteOutput.
|
||||
func TestSynthesizeFehlgeschlagenesEspeak(t *testing.T) {
|
||||
dir := emptyPATH(t)
|
||||
fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`)
|
||||
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
|
||||
if err == nil {
|
||||
t.Fatal("Synthesize mit crashendem espeak: kein Fehler")
|
||||
}
|
||||
if errors.Is(err, ErrNoByteOutput) {
|
||||
t.Errorf("Crashender espeak soll kein ErrNoByteOutput sein: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("Fehler soll stderr des Prozesses enthalten: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakDirectMitFakeSpdSay: SpeakDirect ruft spd-say blockierend
|
||||
// (-w) mit Sprache (-l <lang>) und Text auf.
|
||||
func TestSpeakDirectMitFakeSpdSay(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(), "Hallo Welt", "de"); err != nil {
|
||||
t.Fatalf("SpeakDirect: %v", err)
|
||||
}
|
||||
args, err := os.ReadFile(seen)
|
||||
if err != nil {
|
||||
t.Fatalf("Fake-spd-say wurde nicht aufgerufen: %v", err)
|
||||
}
|
||||
if got := string(args); got != "-w -l de Hallo Welt\n" {
|
||||
t.Errorf("Fake-spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt\n")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakDirectOhneSpdSay ist ein klarer Fehler.
|
||||
func TestSpeakDirectOhneSpdSay(t *testing.T) {
|
||||
emptyPATH(t)
|
||||
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo", "de"); err == nil {
|
||||
t.Error("SpeakDirect ohne spd-say soll fehlschlagen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakDirectEchtesSpdSay läuft nur, wenn TALK_TEST_REAL_SPD=1 gesetzt
|
||||
// ist (erzeugt echte, hörbare Ausgabe über speech-dispatcher — einmalige
|
||||
// manuelle Abnahme; Standard-Testläufe bleiben stumm).
|
||||
func TestSpeakDirectEchtesSpdSay(t *testing.T) {
|
||||
if os.Getenv("TALK_TEST_REAL_SPD") != "1" {
|
||||
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_SPD=1 ausführen")
|
||||
}
|
||||
if _, err := exec.LookPath("spd-say"); err != nil {
|
||||
t.Skip("spd-say nicht installiert")
|
||||
}
|
||||
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Sprachtest über speech dispatcher", "de"); err != nil {
|
||||
t.Fatalf("SpeakDirect gegen echtes spd-say: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// minimalWAV ist eine minimal gültige RIFF/WAVE-Datei (PCM 16 Bit, mono,
|
||||
// 8000 Hz) mit vier Samples — handgebaut nach der RIFF-Spezifikation,
|
||||
// unabhängig vom Parser im audio-Paket.
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user