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

+38 -38
View File
@@ -1,8 +1,8 @@
// Package speak ist der eine Sprech-Kern von talk: Er setzt Config, Audio-
// Sink und Provider-Kette zu einer Ansage zusammen. Der Daemon ruft ihn aus
// dem Queue-Worker, die CLI nur im Notfall ohne Daemon (direkt, ohne
// Warteschlange) — beide über genau diesen Weg, damit es keine zweite
// Zusammensetzung der Kette gibt, die auseinanderlaufen könnte.
// Package speak is talk's single speech core: it assembles config, audio
// sink and provider chain into an announcement. The daemon calls it from
// the queue worker, the CLI only in the emergency path without a daemon
// (directly, without a queue); both go through exactly this path so there
// is no second assembly of the chain that could drift apart.
package speak
import (
@@ -16,37 +16,37 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/provider"
)
// NoProvider ist der Provider-Name, den ActiveProvider meldet, wenn keine
// Stufe der Kette verfügbar ist (Status-Anzeige).
const NoProvider = "keine"
// NoProvider is the provider name ActiveProvider reports when no tier of
// the chain is available (status display).
const NoProvider = "none"
// Request ist eine Ansage: Text plus optionale Pins. Leere Pins heißen
// „Default": Sprache wird erkannt, die Kette läuft in Config-Reihenfolge,
// die Stimme kommt aus dem Voice-Mapping.
// Request is an announcement: text plus optional pins. Empty pins mean
// "default": the language is detected, the chain runs in config order, the
// voice comes from the voice mapping.
type Request struct {
Text string
Lang string // de | en; leer = langdetect
Provider string // Provider-Pin; leer = Config-Pin bzw. Default-Kette
Voice string // Voice-ID-Pin; leer = Mapping/Defaults
Lang string // de | en; empty = langdetect
Provider string // provider pin; empty = config pin or default chain
Voice string // voice ID pin; empty = mapping/defaults
}
// Engine hält Sink und Konfiguration und baut pro Ansage die Kette (Pin und
// Stimme sind Eigenschaften der einzelnen Anfrage). Sie ist nebenläufig
// nutzbar; der Daemon spricht ohnehin seriell.
// Engine holds sink and configuration and builds the chain per announcement
// (pin and voice are properties of the individual request). It is safe for
// concurrent use; the daemon speaks serially anyway.
type Engine struct {
cfg config.Config
sink audio.Sink
getenv func(string) string
log io.Writer
base *provider.Chain // ungepinnte Kette: Verfügbarkeits-Auflösung für Status
base *provider.Chain // unpinned chain: availability resolution for status
mu sync.Mutex
last string // zuletzt verwendeter Provider (Status im Leerlauf)
last string // most recently used provider (status when idle)
}
// New baut den Sprech-Kern: Sink nach cfg.Audio.Sink, Basiskette nach
// Config. getenv liefert Keys und Endpunkt-Overrides (nil = leer), log
// empfängt Ketten- und Sink-Protokoll (nil = still).
// New builds the speech core: sink per cfg.Audio.Sink, base chain per
// config. getenv supplies keys and endpoint overrides (nil = empty), log
// receives chain and sink output (nil = silent).
func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine, error) {
if getenv == nil {
getenv = func(string) string { return "" }
@@ -62,14 +62,14 @@ func New(cfg config.Config, getenv func(string) string, log io.Writer) (*Engine,
return &Engine{cfg: cfg, sink: sink, getenv: getenv, log: log, base: base}, nil
}
// Speak spricht eine Anfrage: Sprache erkennen (falls nicht gepinnt), Kette
// bauen, synthetisieren mit Fallback, über den Sink abspielen. Blockiert
// bis zum Ende der Wiedergabe oder bis ctx abgebrochen wird. Das Outcome
// nennt die Stufe, die wirklich gesprochen hat, und die Zeiten.
// Speak speaks a request: detect the language (unless pinned), build the
// chain, synthesize with fallback, play through the sink. Blocks until
// playback ends or ctx is cancelled. The outcome names the tier that
// actually spoke and the timings.
func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, error) {
lang := req.Lang
if lang == "" {
lang = langdetect.Detect(req.Text)
lang = langdetect.Detect(req.Text, e.cfg.Lang)
}
chain, err := provider.NewChain(chainConfig(e.cfg, req.Provider, req.Voice, e.getenv, e.sink, e.log))
if err != nil {
@@ -82,13 +82,13 @@ func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, erro
return out, err
}
// Chain liefert die ungepinnte Basiskette (Diagnose: Stufen und ihre
// Verfügbarkeit).
// Chain returns the unpinned base chain (diagnostics: tiers and their
// availability).
func (e *Engine) Chain() *provider.Chain { return e.base }
// ActiveProvider löst den „aktiven Provider" für eine Anfrage auf: der Pin,
// sonst die erste verfügbare Stufe der Basiskette (Available ist billig —
// Key-/Binary-Prüfung ohne Netz), sonst NoProvider.
// ActiveProvider resolves the "active provider" for a request: the pin,
// otherwise the first available tier of the base chain (Available is cheap:
// key/binary check without network), otherwise NoProvider.
func (e *Engine) ActiveProvider(pin string) string {
if pin != "" {
return pin
@@ -101,15 +101,15 @@ func (e *Engine) ActiveProvider(pin string) string {
return NoProvider
}
// LastProvider liefert den Provider der zuletzt gestarteten Ansage; leer,
// wenn noch nichts gesprochen wurde.
// LastProvider returns the provider of the most recently started
// announcement; empty if nothing has been spoken yet.
func (e *Engine) LastProvider() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.last
}
// SinkName liefert den Namen des Audio-Sinks (oto | null).
// SinkName returns the name of the audio sink (oto | null).
func (e *Engine) SinkName() string { return e.sink.Name() }
func (e *Engine) setLast(name string) {
@@ -118,9 +118,9 @@ func (e *Engine) setLast(name string) {
e.mu.Unlock()
}
// chainConfig überführt Config + Anfrage-Pins in die ChainConfig: Anfrage-
// Pin schlägt den Config-Pin `provider = "…"`; Endpunkte und das OpenAI-
// Modell kommen aus [providers.<name>].
// chainConfig turns config plus request pins into the ChainConfig: the
// request pin beats the config pin `provider = "..."`; endpoints and the
// OpenAI model come from [providers.<name>].
func chainConfig(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player, log io.Writer) provider.ChainConfig {
if pin == "" {
pin = cfg.Provider