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
@@ -1,6 +1,6 @@
|
|||||||
// Package provider implementiert die TTS-Provider-Kette von talk:
|
// Package provider implementiert die TTS-Provider von talk. Der Vertrag
|
||||||
// ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in (macOS say, Windows SAPI,
|
// (Name/Available/Synthesize) trägt die Fallback-Kette aus Task 03
|
||||||
// Linux espeak/speech-dispatcher), überschreibbar per Config/Flag, mit
|
// (ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in); die dokumentierte
|
||||||
// Fallback bei Fehlern. Implementierung folgt in Task 02 (osbuiltin) und
|
// optionale Erweiterung DirectSpeaker deckt Plattformen ab, deren OS-TTS
|
||||||
// Task 03 (Cloud-Provider, Kette).
|
// keine Audio-Bytes liefern kann (Linux speech-dispatcher).
|
||||||
package provider
|
package provider
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OSBuiltin spricht über die im Betriebssystem eingebaute TTS-Runtime.
|
||||||
|
// Es ist der Notnagel-Provider der Kette: überall vorhanden, ohne Key und
|
||||||
|
// ohne Netz — aber die einfachste Stimme.
|
||||||
|
//
|
||||||
|
// Plattform-Mechanismen (Auswahl zur Laufzeit über runtime.GOOS, damit eine
|
||||||
|
// Binärversion alle Ziele kompiliert; jede Route ist reines exec ohne Shell):
|
||||||
|
//
|
||||||
|
// Linux: Synthesize über espeak/espeak-ng (--stdout → WAV-Bytes).
|
||||||
|
// Fehlt beides, liefert Synthesize ErrNoByteOutput und
|
||||||
|
// SpeakDirect spielt über spd-say (speech-dispatcher) ab —
|
||||||
|
// spd-say kann Audio nach eigener --help-Ausgabe nur selbst über
|
||||||
|
// den Daemon ausgeben, nicht in eine Datei/in einen Prozess
|
||||||
|
// schreiben (verifiziert am Dev-Host 2026-10-18).
|
||||||
|
// macOS: Synthesize über say (-o temporäre WAV-Datei,
|
||||||
|
// --data-format=LEI16@22050); Stimmen de→Anna, en→Samantha
|
||||||
|
// (vorinstalliert; ersetzt durch das Voice-Mapping in Task 03).
|
||||||
|
// Windows: Synthesize über PowerShell System.Speech in temporäre WAV-
|
||||||
|
// Datei (SetOutputToWaveFile); Sprache via SelectVoiceByHints
|
||||||
|
// mit Fallback auf die Systemstimme.
|
||||||
|
//
|
||||||
|
// Fehlende Binaries/Runtimes führen zu Available()==false bzw. klaren
|
||||||
|
// Fehlern — nie zu Panics.
|
||||||
|
type OSBuiltin struct{}
|
||||||
|
|
||||||
|
// Name implementiert Provider.
|
||||||
|
func (OSBuiltin) Name() string { return "osbuiltin" }
|
||||||
|
|
||||||
|
// Available implementiert Provider: true, wenn mindestens ein
|
||||||
|
// Plattform-Mechanismus vorhanden ist.
|
||||||
|
func (OSBuiltin) Available(ctx context.Context) bool {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
_, err := exec.LookPath("say")
|
||||||
|
return err == nil
|
||||||
|
case "windows":
|
||||||
|
_, err := exec.LookPath("powershell")
|
||||||
|
return err == nil
|
||||||
|
default: // linux und sonstige unixartige Systeme
|
||||||
|
for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} {
|
||||||
|
if _, err := exec.LookPath(bin); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) —
|
||||||
|
// abspielbar über jeden Sink des audio-Pakets.
|
||||||
|
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
||||||
|
if text == "" {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: leerer Text")
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return p.synthSay(ctx, text, lang)
|
||||||
|
case "windows":
|
||||||
|
return p.synthSAPI(ctx, text, lang)
|
||||||
|
default:
|
||||||
|
return p.synthEspeak(ctx, text, lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpeakDirect implementiert DirectSpeaker: direkte, blockierende Ausgabe
|
||||||
|
// über das OS-TTS mit eigener Wiedergabe. Notwendig auf Linux ohne espeak;
|
||||||
|
// auf macOS/Windows nicht erforderlich (Synthesize liefert WAV) und ist
|
||||||
|
// dort ein klarer Fehler.
|
||||||
|
func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error {
|
||||||
|
if text == "" {
|
||||||
|
return fmt.Errorf("osbuiltin: leerer Text")
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin", "windows":
|
||||||
|
return fmt.Errorf("osbuiltin: SpeakDirect auf %s nicht nötig — Synthesize liefert WAV; über einen Sink abspielen", runtime.GOOS)
|
||||||
|
default:
|
||||||
|
if _, err := exec.LookPath("spd-say"); err != nil {
|
||||||
|
return fmt.Errorf("osbuiltin: spd-say nicht gefunden (speech-dispatcher installieren oder espeak für Audio-Bytes): %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "spd-say", "-w", "-l", lang, text)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("osbuiltin: spd-say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthEspeak versucht espeak, dann espeak-ng. Beide schreiben WAV
|
||||||
|
// (RIFF/PCM) auf stdout.
|
||||||
|
func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, error) {
|
||||||
|
for _, bin := range []string{"espeak", "espeak-ng"} {
|
||||||
|
if _, err := exec.LookPath(bin); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, bin, "-v", lang, "--stdout", text)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: %s fehlgeschlagen: %w", bin, withStderr(err))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: %s lieferte keine Audiodaten", bin)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("osbuiltin: unter %s kein espeak/espeak-ng für Audio-Bytes gefunden: %w", runtime.GOOS, ErrNoByteOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthSay erzeugt WAV über das macOS-say-Kommando in einer temporären
|
||||||
|
// Datei. --data-format=LEI16@22050 erzwingt 16-Bit-PCM; die Endung .wav
|
||||||
|
// den WAVE-Container.
|
||||||
|
func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error) {
|
||||||
|
if _, err := exec.LookPath("say"); err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: say nicht gefunden: %w", err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp("", "talk-say-*.wav")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
|
||||||
|
}
|
||||||
|
name := tmp.Name()
|
||||||
|
tmp.Close()
|
||||||
|
defer os.Remove(name)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "say", "-v", sayVoice(lang),
|
||||||
|
"--data-format=LEI16@22050", "-o", name, text)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: say fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: say-Ausgabe lesen: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sayVoice mappt Sprachen auf vorinstallierte macOS-Stimmen.
|
||||||
|
func sayVoice(lang string) string {
|
||||||
|
if lang == "en" {
|
||||||
|
return "Samantha" // en-US
|
||||||
|
}
|
||||||
|
return "Anna" // de-DE
|
||||||
|
}
|
||||||
|
|
||||||
|
// synthSAPI erzeugt WAV über die Windows-SAPI (System.Speech) per
|
||||||
|
// PowerShell. Das Skript wird als -EncodedCommand (Base64/UTF-16LE)
|
||||||
|
// übergeben und der Text darin base64-dekodiert übergeben — dadurch gibt
|
||||||
|
// es kein Quoting-Problem mit Sonderzeichen. Ist die Wunschsprache nicht
|
||||||
|
// installiert, fällt SAPI auf die Systemstimme zurück (try/catch).
|
||||||
|
func (OSBuiltin) synthSAPI(ctx context.Context, text, lang string) ([]byte, error) {
|
||||||
|
if _, err := exec.LookPath("powershell"); err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: powershell nicht gefunden: %w", err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp("", "talk-sapi-*.wav")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
|
||||||
|
}
|
||||||
|
name := tmp.Name()
|
||||||
|
tmp.Close()
|
||||||
|
defer os.Remove(name)
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
||||||
|
Add-Type -AssemblyName System.Speech
|
||||||
|
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||||
|
try {
|
||||||
|
$culture = New-Object System.Globalization.CultureInfo('%s')
|
||||||
|
$s.SelectVoiceByHints([System.Speech.Synthesis.VoiceGender]::Female, [System.Speech.Synthesis.VoiceAge]::Adult, 0, $culture)
|
||||||
|
} catch { }
|
||||||
|
$s.SetOutputToWaveFile('%s')
|
||||||
|
$s.Speak([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')))
|
||||||
|
$s.Dispose()
|
||||||
|
`, sapiCulture(lang), name, base64.StdEncoding.EncodeToString([]byte(text)))
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-STA", "-EncodedCommand", psEncode(script))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: SAPI fehlgeschlagen: %w: %s", err, bytes.TrimSpace(out))
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("osbuiltin: SAPI-Ausgabe lesen: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sapiCulture mappt Sprachen auf SAPI-Kulturbezeichner.
|
||||||
|
func sapiCulture(lang string) string {
|
||||||
|
if lang == "en" {
|
||||||
|
return "en-US"
|
||||||
|
}
|
||||||
|
return "de-DE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// psEncode kodiert ein PowerShell-Skript für -EncodedCommand
|
||||||
|
// (UTF-16LE + Base64).
|
||||||
|
func psEncode(script string) string {
|
||||||
|
runes := utf16.Encode([]rune(script))
|
||||||
|
buf := make([]byte, len(runes)*2)
|
||||||
|
for i, r := range runes {
|
||||||
|
binary.LittleEndian.PutUint16(buf[i*2:], r)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withStderr reichert einen exec-Fehler um die gesammelte stderr-Ausgabe
|
||||||
|
// des Prozesses an, wenn vorhanden.
|
||||||
|
func withStderr(err error) error {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
|
||||||
|
return fmt.Errorf("%w: %s", err, bytes.TrimSpace(exitErr.Stderr))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultName ist der Provider, den die Kette nutzt, wenn kein --provider
|
||||||
|
// gepinnt ist. Task 02: osbuiltin; Task 03 ersetzt den Default durch die
|
||||||
|
// Fallback-Kette (ElevenLabs → OpenAI → Edge → osbuiltin).
|
||||||
|
const DefaultName = "osbuiltin"
|
||||||
|
|
||||||
|
// ErrNoByteOutput meldet, dass die Plattform keine über Synthesize
|
||||||
|
// beziehbaren Audio-Bytes liefern kann — konkret Linux mit speech-dispatcher
|
||||||
|
// (spd-say) ohne espeak: spd-say kann Audio nur selbst über den Daemon
|
||||||
|
// abspielen, nicht in einen Prozess schreiben. Aufrufer sollen auf
|
||||||
|
// DirectSpeaker ausweichen (siehe dort).
|
||||||
|
var ErrNoByteOutput = errors.New(
|
||||||
|
"diese Plattform kann keine Audio-Bytes liefern (Linux ohne espeak; spd-say spielt nur direkt)")
|
||||||
|
|
||||||
|
// Provider ist der Vertrag aller TTS-Backends von talk.
|
||||||
|
//
|
||||||
|
// Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der
|
||||||
|
// Implementierung dokumentiert (osbuiltin: RIFF/WAVE mit PCM — von allen
|
||||||
|
// Sinks der Aufgabe abspielbar; Cloud-Provider ab Task 03: mp3).
|
||||||
|
type Provider interface {
|
||||||
|
// Name liefert den eindeutigen Providernamen (CLI --provider, Config).
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// Available meldet, ob der Provider auf diesem System überhaupt nutzbar
|
||||||
|
// ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben-
|
||||||
|
// wirkungen sein; false heißt: Kette soll übersprungen werden.
|
||||||
|
Available(ctx context.Context) bool
|
||||||
|
|
||||||
|
// Synthesize erzeugt Audio-Bytes für text in der Sprache lang.
|
||||||
|
Synthesize(ctx context.Context, text, lang string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectSpeaker ist eine dokumentierte optionale Erweiterung über das
|
||||||
|
// Provider-Interface hinaus (kein Bestandteil des Basisvertrags): Provider,
|
||||||
|
// die Text ohne Umweg über Synthesize-Bytes selbst hörbar ausgeben können.
|
||||||
|
// Notwendig auf Linux-Systemen, deren OS-TTS (speech-dispatcher) keine
|
||||||
|
// Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect
|
||||||
|
// über spd-say selbst ab (blockierend bis zum Ende der Ansage).
|
||||||
|
//
|
||||||
|
// Task 03+ muss diese Erweiterung beachten: In der Kette ist SpeakDirect
|
||||||
|
// die letzte Stufe, wenn kein Provider Bytes liefern kann.
|
||||||
|
type DirectSpeaker interface {
|
||||||
|
// SpeakDirect gibt text in der Sprache lang direkt über das OS-TTS aus
|
||||||
|
// und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen
|
||||||
|
// wird).
|
||||||
|
SpeakDirect(ctx context.Context, text, lang string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// registry bildet Providernamen auf Konstruktoren; Task 03 ergänzt hier
|
||||||
|
// elevenlabs, openai und edge.
|
||||||
|
var registry = map[string]func() Provider{
|
||||||
|
"osbuiltin": func() Provider { return OSBuiltin{} },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names liefert die registrierten Providernamen (sortiert).
|
||||||
|
func Names() []string {
|
||||||
|
names := make([]string, 0, len(registry))
|
||||||
|
for name := range registry {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// New liefert den Provider mit dem angegebenen Namen; "" wählt den Default.
|
||||||
|
// Unbekannte (oder in diesem Stand noch nicht implementierte) Namen sind
|
||||||
|
// ein klarer Fehler.
|
||||||
|
func New(name string) (Provider, error) {
|
||||||
|
if name == "" {
|
||||||
|
name = DefaultName
|
||||||
|
}
|
||||||
|
ctor, ok := registry[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (in diesem Stand verfügbar: %s)",
|
||||||
|
name, strings.Join(Names(), ", "))
|
||||||
|
}
|
||||||
|
return ctor(), nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user