refactor: switch the whole project to English

talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+69 -69
View File
@@ -13,35 +13,36 @@ import (
"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.
// OSBuiltin speaks through the TTS runtime built into the operating system.
// It is the chain's provider of last resort: present everywhere, without a
// key and without network, but with the plainest voice.
//
// Plattform-Mechanismen (Auswahl zur Laufzeit über runtime.GOOS, damit eine
// Binärversion alle Ziele kompiliert; jede Route ist reines exec ohne Shell):
// Platform mechanisms (selected at runtime via runtime.GOOS so that one
// binary compiles for all targets; every route is pure exec without a
// 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.
// Linux: Synthesize via espeak/espeak-ng (--stdout → WAV bytes).
// If both are missing, Synthesize returns ErrNoByteOutput and
// SpeakDirect plays via spd-say (speech-dispatcher); according
// to its own --help output spd-say can only emit audio itself
// through the daemon, not write it to a file or a process
// (verified on the dev host 2026-10-18).
// macOS: Synthesize via say (-o temporary WAV file,
// --data-format=LEI16@22050); voices de→Anna, en→Samantha
// (preinstalled).
// Windows: Synthesize via PowerShell System.Speech into a temporary WAV
// file (SetOutputToWaveFile); language via SelectVoiceByHints
// with fallback to the system voice.
//
// Fehlende Binaries/Runtimes führen zu Available()==false bzw. klaren
// Fehlern — nie zu Panics.
// Missing binaries/runtimes lead to Available()==false or clear errors,
// never to panics.
type OSBuiltin struct{}
// Name implementiert Provider.
// Name implements Provider.
func (OSBuiltin) Name() string { return "osbuiltin" }
// Available implementiert Provider: true, wenn mindestens ein
// Plattform-Mechanismus vorhanden ist.
// Available implements Provider: true if at least one platform mechanism is
// present.
func (OSBuiltin) Available(ctx context.Context) bool {
switch runtime.GOOS {
case "darwin":
@@ -50,7 +51,7 @@ func (OSBuiltin) Available(ctx context.Context) bool {
case "windows":
_, err := exec.LookPath("powershell")
return err == nil
default: // linux und sonstige unixartige Systeme
default: // linux and other unix-like systems
for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} {
if _, err := exec.LookPath(bin); err == nil {
return true
@@ -60,40 +61,40 @@ func (OSBuiltin) Available(ctx context.Context) bool {
}
}
// 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.
// Describe names the platform mechanism found, for diagnosis
// (`talk doctor`): e.g. "espeak-ng (audio bytes)", "spd-say (direct speech
// only; install espeak-ng for audio bytes)", "say", "powershell (SAPI)",
// or what is missing.
func (OSBuiltin) Describe() string {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("say"); err == nil {
return "say (macOS)"
}
return "say nicht gefunden"
return "say not found"
case "windows":
if _, err := exec.LookPath("powershell"); err == nil {
return "powershell (SAPI System.Speech)"
}
return "powershell nicht gefunden"
return "powershell not found"
default:
for _, bin := range []string{"espeak-ng", "espeak"} {
if _, err := exec.LookPath(bin); err == nil {
return bin + " (Audio-Bytes)"
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 "spd-say (direct speech only; install espeak-ng for audio bytes)"
}
return "weder espeak-ng/espeak noch spd-say gefunden (espeak-ng installieren)"
return "neither espeak-ng/espeak nor spd-say found (install espeak-ng)"
}
}
// Synthesize implementiert Provider. Rückgabeformat: RIFF/WAVE (PCM) —
// abspielbar über jeden Sink des audio-Pakets.
// Synthesize implements Provider. Return format: RIFF/WAVE (PCM), playable
// through every sink of the audio package.
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("osbuiltin: leerer Text")
return nil, fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin":
@@ -105,31 +106,30 @@ func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, e
}
}
// 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.
// SpeakDirect implements DirectSpeaker: direct, blocking output through the
// OS TTS with its own playback. Required on Linux without espeak; not
// needed on macOS/Windows (Synthesize returns WAV) and a clear error there.
func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error {
if text == "" {
return fmt.Errorf("osbuiltin: leerer Text")
return fmt.Errorf("osbuiltin: empty 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)
return fmt.Errorf("osbuiltin: SpeakDirect not needed on %s; Synthesize returns WAV, play it through a sink", 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)
return fmt.Errorf("osbuiltin: spd-say not found (install speech-dispatcher, or espeak for 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 fmt.Errorf("osbuiltin: spd-say failed: %w: %s", err, bytes.TrimSpace(out))
}
return nil
}
}
// synthEspeak versucht espeak, dann espeak-ng. Beide schreiben WAV
// (RIFF/PCM) auf stdout.
// synthEspeak tries espeak, then espeak-ng. Both write WAV (RIFF/PCM) to
// 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 {
@@ -138,26 +138,26 @@ func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, er
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))
return nil, fmt.Errorf("osbuiltin: %s failed: %w", bin, withStderr(err))
}
if len(out) == 0 {
return nil, fmt.Errorf("osbuiltin: %s lieferte keine Audiodaten", bin)
return nil, fmt.Errorf("osbuiltin: %s returned no audio data", bin)
}
return out, nil
}
return nil, fmt.Errorf("osbuiltin: unter %s kein espeak/espeak-ng für Audio-Bytes gefunden: %w", runtime.GOOS, ErrNoByteOutput)
return nil, fmt.Errorf("osbuiltin: no espeak/espeak-ng for audio bytes found on %s: %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.
// synthSay produces WAV via the macOS say command into a temporary file.
// --data-format=LEI16@22050 forces 16-bit PCM; the .wav extension forces
// the 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)
return nil, fmt.Errorf("osbuiltin: say not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-say-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
@@ -166,16 +166,16 @@ func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error
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))
return nil, fmt.Errorf("osbuiltin: say failed: %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 nil, fmt.Errorf("osbuiltin: reading say output: %w", err)
}
return data, nil
}
// sayVoice mappt Sprachen auf vorinstallierte macOS-Stimmen.
// sayVoice maps languages to preinstalled macOS voices.
func sayVoice(lang string) string {
if lang == "en" {
return "Samantha" // en-US
@@ -183,18 +183,18 @@ func sayVoice(lang string) string {
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).
// synthSAPI produces WAV via the Windows SAPI (System.Speech) through
// PowerShell. The script is passed as -EncodedCommand (Base64/UTF-16LE) and
// the text inside it is passed base64-decoded, so there is no quoting
// problem with special characters. If the requested language is not
// installed, SAPI falls back to the system voice (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)
return nil, fmt.Errorf("osbuiltin: powershell not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-sapi-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporäre Datei: %w", err)
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
@@ -214,16 +214,16 @@ $s.Dispose()
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))
return nil, fmt.Errorf("osbuiltin: SAPI failed: %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 nil, fmt.Errorf("osbuiltin: reading SAPI output: %w", err)
}
return data, nil
}
// sapiCulture mappt Sprachen auf SAPI-Kulturbezeichner.
// sapiCulture maps languages to SAPI culture identifiers.
func sapiCulture(lang string) string {
if lang == "en" {
return "en-US"
@@ -231,7 +231,7 @@ func sapiCulture(lang string) string {
return "de-DE"
}
// psEncode kodiert ein PowerShell-Skript für -EncodedCommand
// psEncode encodes a PowerShell script for -EncodedCommand
// (UTF-16LE + Base64).
func psEncode(script string) string {
runes := utf16.Encode([]rune(script))
@@ -242,8 +242,8 @@ func psEncode(script string) string {
return base64.StdEncoding.EncodeToString(buf)
}
// withStderr reichert einen exec-Fehler um die gesammelte stderr-Ausgabe
// des Prozesses an, wenn vorhanden.
// withStderr enriches an exec error with the process's collected stderr
// output, if any.
func withStderr(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {