diff --git a/internal/provider/doc.go b/internal/provider/doc.go index 6365d28..8310813 100644 --- a/internal/provider/doc.go +++ b/internal/provider/doc.go @@ -1,6 +1,6 @@ -// Package provider implementiert die TTS-Provider-Kette von talk: -// ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in (macOS say, Windows SAPI, -// Linux espeak/speech-dispatcher), überschreibbar per Config/Flag, mit -// Fallback bei Fehlern. Implementierung folgt in Task 02 (osbuiltin) und -// Task 03 (Cloud-Provider, Kette). +// Package provider implementiert die TTS-Provider von talk. Der Vertrag +// (Name/Available/Synthesize) trägt die Fallback-Kette aus Task 03 +// (ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in); die dokumentierte +// optionale Erweiterung DirectSpeaker deckt Plattformen ab, deren OS-TTS +// keine Audio-Bytes liefern kann (Linux speech-dispatcher). package provider diff --git a/internal/provider/osbuiltin.go b/internal/provider/osbuiltin.go new file mode 100644 index 0000000..3d904f2 --- /dev/null +++ b/internal/provider/osbuiltin.go @@ -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 +} diff --git a/internal/provider/osbuiltin_test.go b/internal/provider/osbuiltin_test.go new file mode 100644 index 0000000..690dcd1 --- /dev/null +++ b/internal/provider/osbuiltin_test.go @@ -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 ) 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 ) 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 +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..637407b --- /dev/null +++ b/internal/provider/provider.go @@ -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 +}