Usability-Schicht für Nutzer, die kein Terminal-Handbuch lesen wollen: - `talk doctor`: eine Zeile je Prüfpunkt mit klarem Satz, was Sache ist und was zu tun wäre — Config (kaputt → ✘, die übrigen Prüfungen laufen mit Defaults weiter), Daemon (läuft / startet beim nächsten Sprechbefehl / fremder Dienst auf dem Port → ✘ mit Port-Hinweis), Audio (oto-Kontext wirklich initialisieren; null → ⚠), jede Provider-Stufe (Key-Env-Name, Endpunkt, Default-Stimme; OS-Stimme nennt den gefundenen Mechanismus), Mute (an → ⚠ mit unmute-Hinweis), Gesetze inkl. gerade aktiver Ruhezeit. --probe macht echte Synthese-Proben, --json liefert die Liste. Exit 0 ohne ✘. Windows-Konsolen bekommen [OK]/[WARN]/[FAIL]. - `talk test`: Testsatz direkt über den Sprech-Kern (nicht über den Daemon), nennt die Stufe, die wirklich gesprochen hat, Synthese- und Wiedergabezeit; Mute/Ruhezeit gelten bewusst nicht, Mute wird genannt. - `talk voices`: kuratierter Katalog mit Namen (ElevenLabs-Premade, OpenAI, Edge-Neural de/en-Locales) als Tabelle, „*“ = effektive Default-Stimme (Config vor Repo-Default), Config-IDs außerhalb des Katalogs als eigene Zeile, Filter --provider/--lang, --json. - `talk setup`: wenige Fragen statt Bubble Tea — Keys nur als Hinweis (ohne Key läuft die Edge-Stimme), Stimme je Sprache aus dem Katalog mit Probehören, Ruhezeit; schreibt die config.toml zusammengeführt mit bestehenden Werten (.bak), spricht einen Testsatz. q bricht ab, bevor etwas geschrieben wird; ohne Terminal keine Fragen, nur die wirksame Konfiguration. Unterbau: provider.Catalog/VoiceName/DefaultVoice, Chain.SpeakOutcome (Stufe, Bytes, Zeiten) durch speak.Engine bis in Daemon-Log und test, audio.Check (oto-Initialisierung ohne Ton), config.Path, OSBuiltin.Describe. README: Abschnitt „Erste Schritte“. Tests: voices-Zeilen (Default-Markierung, Config-Override, Filter, JSON), doctor (ohne Daemon, Mute/Key via JSON, fremder Dienst → Exit 1, kaputte Config → ✘ aber vollständiger Bericht), test (fake espeak), setup (stdin-Antworten → config.toml-Inhalt, Merge mit .bak, Abbruch schreibt nichts, Pipe stellt keine Fragen), Prozessgrenze: doctor/--probe/voices/ test gegen echten Daemon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
254 lines
8.7 KiB
Go
254 lines
8.7 KiB
Go
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
|
|
}
|
|
}
|
|
|
|
// Describe nennt den gefundenen Plattform-Mechanismus für die Diagnose
|
|
// (`talk doctor`): z. B. „espeak-ng (Audio-Bytes)", „spd-say (nur
|
|
// Direktansage; espeak-ng für Audio-Bytes fehlt)", „say", „powershell
|
|
// (SAPI)" — oder, was fehlt.
|
|
func (OSBuiltin) Describe() string {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
if _, err := exec.LookPath("say"); err == nil {
|
|
return "say (macOS)"
|
|
}
|
|
return "say nicht gefunden"
|
|
case "windows":
|
|
if _, err := exec.LookPath("powershell"); err == nil {
|
|
return "powershell (SAPI System.Speech)"
|
|
}
|
|
return "powershell nicht gefunden"
|
|
default:
|
|
for _, bin := range []string{"espeak-ng", "espeak"} {
|
|
if _, err := exec.LookPath(bin); err == nil {
|
|
return bin + " (Audio-Bytes)"
|
|
}
|
|
}
|
|
if _, err := exec.LookPath("spd-say"); err == nil {
|
|
return "spd-say (nur Direktansage; espeak-ng für Audio-Bytes fehlt)"
|
|
}
|
|
return "weder espeak-ng/espeak noch spd-say gefunden (espeak-ng installieren)"
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|