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

+77 -78
View File
@@ -16,20 +16,19 @@ import (
"github.com/gorilla/websocket"
)
// Edge-TTS-Konstanten. Das Protokoll ist das bekannte, von edge-tts
// reverse-engineerte „Read Aloud"-Protokoll von Microsoft Edge — Stand der
// Implementierung (2026): DRM-Token Sec-MS-GEC v2 (CS64 + verschleierte
// SHA-256-Variante, siehe edgedrm.go) + MUID-Cookie, im Übrigen das
// bekannte WebSocket-Nachrichtenformat (speech.config + SSML, Binär-Rahmen
// mit 2-Byte-Header-Länge, turn.end). Ändert Microsoft es, liefern die
// Synthese-Pfade klare Fehler mit Hinweis auf Protokoll-Änderung statt
// stiller Fehlfunktion.
// Edge TTS constants. The protocol is the well-known "Read Aloud" protocol
// of Microsoft Edge, reverse-engineered by edge-tts. State of the
// implementation (2026): DRM token Sec-MS-GEC v2 (CS64 + obfuscated SHA-256
// variant, see edgedrm.go) + MUID cookie; otherwise the known WebSocket
// message format (speech.config + SSML, binary frames with a 2-byte header
// length, turn.end). If Microsoft changes it, the synthesis paths return
// clear errors hinting at a protocol change instead of failing silently.
const (
// DefaultEndpointEdge ist der produktive WebSocket-Endpunkt (Pfad ohne
// Query — Token/ConnectionId/Sec-MS-GEC werden in requestURL ergänzt).
// DefaultEndpointEdge is the production WebSocket endpoint (path without
// query; token/ConnectionId/Sec-MS-GEC are added in requestURL).
DefaultEndpointEdge = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
// EnvEdgeEndpoint überschreibt den Endpunkt (Tests/Firmenserver).
// EnvEdgeEndpoint overrides the endpoint (tests/corporate servers).
EnvEdgeEndpoint = "TALK_EDGE_ENDPOINT"
edgeTrustedClientToken = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
@@ -39,36 +38,36 @@ const (
edgeOutputFormat = "audio-24khz-48kbitrate-mono-mp3" // mp3!
)
// edgeIOTimeout begrenzt einzelne Lese-/Schreiboperationen sowie (wenn der
// Aufrufer keine Deadline mitgibt) die gesamte Synthese.
// edgeIOTimeout bounds individual read/write operations and (if the caller
// provides no deadline) the whole synthesis.
const edgeIOTimeout = 20 * time.Second
// edgeClockSkew korrigiert lokale Uhrenabweichung: Bei 403 liest die Kette
// das Date-Header-Feld der Antwort und justiert die Tick-Berechnung (ein
// Wiederholungsversuch, wie die edge-tts-Referenz).
// edgeClockSkew corrects local clock drift: on 403 the provider reads the
// Date header of the response and adjusts the tick calculation (one retry,
// like the edge-tts reference).
var edgeClockSkew atomic.Int64
// Edge ist die kostenlose dritte Stufe der Kette: Microsofts „Read Aloud"-
// Endpoint, kein Key nötig. Synthesize öffnet eine WebSocket-Verbindung,
// sendet speech.config + SSML-Anfrage und sammelt die Binär-Audio-Frames
// bis turn.end. Rückgabeformat: MP3 (24 kHz mono — der audio-Sink
// dekodiert das Format).
// Edge is the free third tier of the chain: Microsoft's "Read Aloud"
// endpoint, no key needed. Synthesize opens a WebSocket connection, sends
// speech.config + the SSML request and collects the binary audio frames
// until turn.end. Return format: MP3 (24 kHz mono; the audio sink decodes
// the format).
type Edge struct {
// Endpoint ist die WebSocket-URL (ws:// oder wss://), leer = Default.
// Endpoint is the WebSocket URL (ws:// or wss://), empty = default.
Endpoint string
// Voices ist das config-Mapping voice.edge.<lang> = id; leer = neutrale
// Repo-Defaults (de-DE-KatjaNeural / en-US-AriaNeural).
// Voices is the config mapping voice.edge.<lang> = id; empty = neutral
// repo defaults (de-DE-KatjaNeural / en-US-AriaNeural).
Voices map[string]string
// Dialer ist der WebSocket-Dialer; nil = websocket.DefaultDialer.
// Dialer is the WebSocket dialer; nil = websocket.DefaultDialer.
Dialer *websocket.Dialer
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewEdge baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > Default.
// NewEdge builds the provider from the chain configuration: endpoint from
// TALK_EDGE_ENDPOINT > [providers.edge].endpoint > default.
func NewEdge(cfg ChainConfig) *Edge {
return &Edge{
Endpoint: endpointFrom(cfg, "edge", EnvEdgeEndpoint, DefaultEndpointEdge),
@@ -76,25 +75,25 @@ func NewEdge(cfg ChainConfig) *Edge {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*Edge) Name() string { return "edge" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *Edge) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Edge braucht nur einen Endpunkt —
// bewusst ohne Netz-Call (Erreichbarkeit stellt Synthesize fest, an deren
// Fehler fällt die Kette weiter).
// Available implements Provider: Edge only needs an endpoint, deliberately
// without a network call (reachability is determined by Synthesize, on
// whose error the chain falls back).
func (p *Edge) Available(ctx context.Context) bool { return p.Endpoint != "" }
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("edge: leerer Text")
return nil, fmt.Errorf("edge: empty text")
}
voice := resolveVoice("edge", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("edge: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("edge: no voice configured for language %q", lang)
}
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
@@ -135,7 +134,7 @@ func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error
case websocket.TextMessage:
if strings.Contains(string(msg), "Path:turn.end") {
if audio.Len() == 0 {
return nil, fmt.Errorf("edge: Server meldete turn.end ohne Audio-Frames — Protokoll geändert? (%s)", redactURL(wsURL))
return nil, fmt.Errorf("edge: server sent turn.end without audio frames; protocol changed? (%s)", redactURL(wsURL))
}
return audio.Bytes(), nil
}
@@ -148,8 +147,8 @@ func (p *Edge) Synthesize(ctx context.Context, text, lang string) ([]byte, error
}
}
// dialWithSkewRetry baut die Verbindung; bei 403 (Uhrabweichung) wird der
// Server-Zeitstempel übernommen und EINMAL wiederholt.
// dialWithSkewRetry establishes the connection; on 403 (clock drift) the
// server timestamp is adopted and the dial is retried ONCE.
func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string, error) {
conn, wsURL, resp, err := p.dial(ctx)
if err == nil {
@@ -167,16 +166,16 @@ func (p *Edge) dialWithSkewRetry(ctx context.Context) (*websocket.Conn, string,
}
}
if resp != nil {
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen (HTTP %d; Netz/Timeout oder Protokoll geändert?): %w",
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed (HTTP %d; network/timeout, or the Edge TTS protocol changed?): %w",
redactURL(wsURL), resp.StatusCode, err)
}
return nil, wsURL, fmt.Errorf("edge: WebSocket-Verbindung zu %s fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w",
return nil, wsURL, fmt.Errorf("edge: WebSocket connection to %s failed; network/timeout, or the Edge TTS protocol changed: %w",
redactURL(wsURL), err)
}
// dial öffnet die Verbindung gemäß Protokoll: Query mit Token,
// ConnectionId, Sec-MS-GEC (v2) und Version; Header mit Origin,
// Edge-User-Agent und MUID-Cookie.
// dial opens the connection according to the protocol: query with token,
// ConnectionId, Sec-MS-GEC (v2) and version; headers with Origin, Edge
// user agent and MUID cookie.
func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Response, error) {
wsURL, err := p.requestURL(time.Now())
if err != nil {
@@ -200,18 +199,18 @@ func (p *Edge) dial(ctx context.Context) (*websocket.Conn, string, *http.Respons
return conn, wsURL, nil, nil
}
// requestURL ergänzt die Protokoll-Query-Parameter und validiert das
// Schema (ws/wss).
// requestURL adds the protocol query parameters and validates the scheme
// (ws/wss).
func (p *Edge) requestURL(now time.Time) (string, error) {
if p.Endpoint == "" {
return "", fmt.Errorf("edge: kein Endpunkt konfiguriert")
return "", fmt.Errorf("edge: no endpoint configured")
}
u, err := url.Parse(p.Endpoint)
if err != nil {
return "", fmt.Errorf("edge: Endpunkt %q unlesbar: %w", p.Endpoint, err)
return "", fmt.Errorf("edge: endpoint %q unreadable: %w", p.Endpoint, err)
}
if u.Scheme != "ws" && u.Scheme != "wss" {
return "", fmt.Errorf("edge: Endpunkt %q muss ws:// oder wss:// sein (ist %q)", p.Endpoint, u.Scheme)
return "", fmt.Errorf("edge: endpoint %q must be ws:// or wss:// (is %q)", p.Endpoint, u.Scheme)
}
q := u.Query()
q.Set("TrustedClientToken", edgeTrustedClientToken)
@@ -222,12 +221,12 @@ func (p *Edge) requestURL(now time.Time) (string, error) {
return u.String(), nil
}
// edgeSecMSGEC erzeugt den DRM-Token Sec-MS-GEC v2: Windows-Filetime-Ticks
// (100 ns, 5-Minuten-Raster, Uhrenkorrektur) + Trusted-Client-Token →
// „<CS64>-<SHA256-Variante>" (beides großes Hex, siehe edgedrm.go).
// edgeSecMSGEC produces the DRM token Sec-MS-GEC v2: Windows filetime ticks
// (100 ns, 5-minute grid, clock correction) + trusted client token →
// "<CS64>-<SHA256 variant>" (both upper-case hex, see edgedrm.go).
func edgeSecMSGEC(now time.Time) string {
secs := now.Unix() + edgeClockSkew.Load() + drmWinEpoch
secs -= secs % 300 // 5-Minuten-Raster
secs -= secs % 300 // 5-minute grid
ticks := uint64(secs) * 10_000_000
strToHash := fmt.Appendf(nil, "%d%s", ticks, edgeTrustedClientToken)
digest := drmSHA256Digest(strToHash)
@@ -253,16 +252,16 @@ func edgeSecMSGEC(now time.Time) string {
return strings.ToUpper(hex.EncodeToString(cs64Buf[:])) + "-" + strings.ToUpper(hex.EncodeToString(shaBuf[:]))
}
// protocolErr überführt Lese-/Schreib-/Verbindungsabbrüche in klare
// „Protokoll geändert?"-Fehler — Edge ist ein undokumentierter Endpoint,
// Änderungen sollen sofort diagnostizierbar sein.
// protocolErr turns read/write/connection aborts into clear "protocol
// changed?" errors; Edge is an undocumented endpoint and changes should be
// diagnosable immediately.
func protocolErr(err error) error {
return fmt.Errorf("edge: Kommunikation fehlgeschlagen — Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert: %w", err)
return fmt.Errorf("edge: communication failed; network/timeout, or the Edge TTS protocol changed: %w", err)
}
// splitEdgeBinary zerlegt eine binäre Server-Nachricht: Die ersten 2 Bytes
// (big-endian) geben die Länge des Header-Blocks an (CRLF-Zeilen wie
// „Path:audio"), danach kommt die Audio-Payload.
// splitEdgeBinary splits a binary server message: the first 2 bytes
// (big-endian) give the length of the header block (CRLF lines such as
// "Path:audio"), followed by the audio payload.
func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
if len(msg) < 2 {
return "", nil, false
@@ -274,9 +273,9 @@ func splitEdgeBinary(msg []byte) (header string, payload []byte, ok bool) {
return string(msg[2 : 2+headerLen]), msg[2+headerLen:], true
}
// edgeSpeechConfigMsg ist die erste Client-Nachricht: Header-Block +
// JSON-Body mit Ausgabeformat und Metadaten-Optionen (Satzgrenzen an,
// Wortgrenzen aus — Referenz-Default).
// edgeSpeechConfigMsg is the first client message: header block + JSON body
// with output format and metadata options (sentence boundaries on, word
// boundaries off; reference default).
func edgeSpeechConfigMsg(now time.Time) string {
return fmt.Sprintf(
"X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n"+
@@ -284,17 +283,17 @@ func edgeSpeechConfigMsg(now time.Time) string {
edgeTimestamp(now), edgeOutputFormat)
}
// edgeSynthesisHeaders ist der Header-Block der SSML-Anfrage. Der
// nachgestellte Z beim Timestamp ist Referenz-Verhalten („Microsoft Edge
// bug"), bewusst mitgeschleppt.
// edgeSynthesisHeaders is the header block of the SSML request. The
// trailing Z on the timestamp is reference behaviour ("Microsoft Edge
// bug"), deliberately carried along.
func edgeSynthesisHeaders(now time.Time, requestID string) string {
return fmt.Sprintf(
"X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n",
requestID, edgeTimestamp(now))
}
// edgeSSML baut die Sprach-Anfrage (xml:lang wie die Referenz fest
// „en-US"; der Text wird von Steuerzeichen befreit und XML-escapt).
// edgeSSML builds the speech request (xml:lang fixed to "en-US" like the
// reference; the text is stripped of control characters and XML-escaped).
func edgeSSML(voice, text string) string {
return fmt.Sprintf(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"+
@@ -304,8 +303,8 @@ func edgeSSML(voice, text string) string {
voice, edgeEscape(text))
}
// edgeEscape entfernt nicht unterstützte Steuerzeichen (Referenz:
// remove_incompatible_characters) und escapt danach & < >.
// edgeEscape removes unsupported control characters (reference:
// remove_incompatible_characters) and then escapes & < >.
func edgeEscape(s string) string {
clean := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
@@ -331,27 +330,27 @@ func edgeEscape(s string) string {
return string(esc)
}
// edgeTimestamp formatiert den Zeitstempel im edge-tts-Format.
// edgeTimestamp formats the timestamp in the edge-tts format.
func edgeTimestamp(now time.Time) string {
return now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
}
// edgeRequestID erzeugt eine 32-stellige Hex-ID (UUID v4 ohne Striche).
// edgeRequestID produces a 32-character hex ID (UUID v4 without dashes).
func edgeRequestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Fallback: zeitbasiert — die ID muss nur eindeutig pro Anfrage sein.
// Fallback: time based; the ID only has to be unique per request.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
b[i] = byte(now >> (uint(i%8) * 8))
}
}
b[6] = (b[6] & 0x0f) | 0x40 // UUID-Version
b[8] = (b[8] & 0x3f) | 0x80 // Variante
b[6] = (b[6] & 0x0f) | 0x40 // UUID version
b[8] = (b[8] & 0x3f) | 0x80 // variant
return hex.EncodeToString(b[:])
}
// edgeMUID erzeugt die zufällige MUID für das Cookie (32 Hex, groß).
// edgeMUID produces the random MUID for the cookie (32 hex, upper case).
func edgeMUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
@@ -360,8 +359,8 @@ func edgeMUID() string {
return strings.ToUpper(hex.EncodeToString(b[:]))
}
// redactURL maskiert die Query-Parameter (enthalten DRM-Token und IDs) in
// Fehlermeldungen — die URL selbst genügt zur Diagnose.
// redactURL masks the query parameters (they contain the DRM token and
// IDs) in error messages; the URL itself is enough for diagnosis.
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {