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 }