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

+19 -20
View File
@@ -11,19 +11,19 @@ import (
"time"
)
// Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
// dieselbe Vertragsschnittstelle, die server.go bedient.
// Client is the CLI's HTTP client for the daemon on 127.0.0.1; the same
// contract that server.go serves.
type Client struct {
// BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
// BaseURL is the daemon address (http://127.0.0.1:<port>).
BaseURL string
// HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
// HTTP is the client in use; NewClient sets it when nil.
HTTP *http.Client
}
// NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
// Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
// großzügiger); das Client-Timeout ist nur die harte Decke.
// NewClient builds the client for a daemon port. The caller controls time
// budgets per call via the context (daemon detection short, /speak more
// generous); the client timeout is only the hard ceiling.
func NewClient(port int) *Client {
return &Client{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
@@ -31,10 +31,10 @@ func NewClient(port int) *Client {
}
}
// HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
// 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
// *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
// Netz-Fehler → Degraded Mode.
// HTTPError is a daemon response with an unexpected status code (e.g. 400
// validation). Network errors (daemon dead) are regular *url.Error values
// instead; the CLI distinguishes by that: show HTTP errors, fall into
// degraded mode on network errors.
type HTTPError struct {
Status int
Body string
@@ -44,7 +44,7 @@ func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
}
// Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
// Status calls GET /status (daemon detection, --wait polling).
func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
var st StatusResponse
if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
@@ -53,7 +53,7 @@ func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
return &st, nil
}
// Speak ruft POST /speak (enqueue).
// Speak calls POST /speak (enqueue).
func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
var resp SpeakResponse
if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
@@ -62,8 +62,7 @@ func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, e
return &resp, nil
}
// Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
// Ansagen.
// Cancel calls POST /cancel; returns the number of cancelled announcements.
func (c *Client) Cancel(ctx context.Context) (int, error) {
var resp CancelResponse
if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
@@ -72,7 +71,7 @@ func (c *Client) Cancel(ctx context.Context) (int, error) {
return resp.Cancelled, nil
}
// SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
// SetMute calls POST /mute (muted=true) or POST /unmute (muted=false).
func (c *Client) SetMute(ctx context.Context, muted bool) error {
path := "/mute"
if !muted {
@@ -81,14 +80,14 @@ func (c *Client) SetMute(ctx context.Context, muted bool) error {
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
}
// Shutdown ruft POST /shutdown (`talk daemon stop`).
// Shutdown calls POST /shutdown (`talk daemon stop`).
func (c *Client) Shutdown(ctx context.Context) error {
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
}
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
// über errors.As(&HTTPError)).
// do is the shared core: build the request, send it, check the status,
// decode. Network errors are returned unfiltered (the caller distinguishes
// via errors.As(&HTTPError)).
func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
var rd io.Reader
if body != nil {
+39 -42
View File
@@ -5,28 +5,26 @@ import (
"strings"
)
// CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein
// Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage-
// Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis
// kommt ausschließlich aus der Config-Liste server.allowed_origins
// (Env TALK_ALLOWED_ORIGINS): exakte Origins wie
// "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin
// ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine
// CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird).
// CORS door for POST /speak, and ONLY for this endpoint: a browser (e.g. a
// later LibreChat integration) may call exactly the announcement interface
// cross-origin, never the control endpoints. Permission comes exclusively
// from the config list server.allowed_origins (env TALK_ALLOWED_ORIGINS):
// exact origins such as "https://chat.example.com"; the entry "*" explicitly
// allows any origin. Deliberate default: EMPTY. The daemon then sends no
// CORS headers at all and the browser blocks (door closed until configured).
//
// Die Tür ist serverseitig zu, nicht nur browserseitig (originGuard): CORS
// allein verhindert nur das LESEN der Antwort, nicht die Wirkung — ein
// Browser schickt einen POST mit text/plain-Body oder ein Formular ohne
// Preflight ab, und ein Server, der den Body dennoch als JSON liest, hätte
// gesprochen, gemutet oder abgebrochen. Deshalb lehnt talkd jede Anfrage
// mit fremdem Origin-Header ab (403), die nicht der erlaubte Weg über
// /speak ist. Nur der Preflight OPTIONS /speak bleibt für ungelistete
// Origins ein 204 ohne CORS-Header (er hat keine Wirkung; der Browser
// verwirft den geplanten POST). CLI, curl und andere Nicht-Browser-Clients
// senden keinen Origin-Header und sind nicht betroffen.
// The door is closed server-side, not only browser-side (originGuard): CORS
// alone only prevents READING the response, not the effect. A browser sends
// a POST with a text/plain body or a form without preflight, and a server
// that still reads the body as JSON would have spoken, muted or cancelled.
// Therefore talkd rejects every request with a foreign Origin header (403)
// that is not the permitted path via /speak. Only the preflight OPTIONS
// /speak stays a 204 without CORS headers for unlisted origins (it has no
// effect; the browser drops the planned POST). CLI, curl and other
// non-browser clients send no Origin header and are unaffected.
// CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST
// dieselben setzen).
// CORS response headers (names in one place so preflight and the real POST
// set the same ones).
const (
headerAllowOrigin = "Access-Control-Allow-Origin"
headerAllowMethods = "Access-Control-Allow-Methods"
@@ -35,13 +33,13 @@ const (
headerVary = "Vary"
)
// preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine
// Chat-Session und halten den OPTIONS-Chatter klein.
// preflightAge is the preflight cache duration: 10 minutes cover a chat
// session and keep the OPTIONS chatter small.
const preflightAge = "600"
// corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard-
// Konfiguration das literale "*", sonst die exakt gematchte Origin (die
// identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt.
// corsACAO returns the value for Access-Control-Allow-Origin: the literal
// "*" for a wildcard configuration, otherwise the exactly matched origin
// (identical to the config entry). Empty means: not allowed.
func (h *handler) corsACAO(r *http.Request) string {
origin := r.Header.Get("Origin")
if origin == "" {
@@ -58,10 +56,9 @@ func (h *handler) corsACAO(r *http.Request) string {
return ""
}
// setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten,
// wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei
// geschlossener Tür), damit Caches die Antwort nie fälschlich origin-
// übergreifend wiederverwenden.
// setSpeakCors sets the CORS headers on real POST /speak responses when the
// request origin is allowed. Vary: Origin is always present (even with the
// door closed) so caches never wrongly reuse the response across origins.
func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
w.Header().Add(headerVary, "Origin")
if acao := h.corsACAO(r); acao != "" {
@@ -69,11 +66,11 @@ func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
}
}
// speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den
// Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header
// sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft
// den eigentlichen Request, während der Daemon für Nicht-Browser-Clients
// erreichbar bleibt.
// speakPreflight handles OPTIONS /speak (CORS preflight): 204 with the
// permission headers for a listed origin, 204 without any CORS headers
// otherwise (unlisted, missing or door completely closed). The browser
// drops the actual request while the daemon stays reachable for
// non-browser clients.
func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
w.Header().Add(headerVary, "Origin")
if acao := h.corsACAO(r); acao != "" {
@@ -85,13 +82,13 @@ func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// originGuard ist die Middleware vor dem Router: Anfragen mit Origin-Header
// (also aus einem Browser) kommen nur als OPTIONS /speak (Preflight, ohne
// Wirkung) oder als POST /speak von gelisteter Origin durch. Alles andere —
// ungelistete Origin auf /speak, jeder Browser-Zugriff auf Steuer-Endpunkte
// und /mcp — wird mit 403 abgelehnt, bevor ein Body gelesen oder eine
// Wirkung ausgelöst wird. Ohne Origin-Header (CLI, curl, MCP-Clients)
// passiert nichts.
// originGuard is the middleware in front of the router: requests with an
// Origin header (i.e. from a browser) only pass as OPTIONS /speak (preflight,
// no effect) or as POST /speak from a listed origin. Everything else
// (unlisted origin on /speak, any browser access to control endpoints and
// /mcp) is rejected with 403 before a body is read or an effect is
// triggered. Without an Origin header (CLI, curl, MCP clients) nothing
// happens.
func (h *handler) originGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
@@ -106,6 +103,6 @@ func (h *handler) originGuard(next http.Handler) http.Handler {
}
w.Header().Add(headerVary, "Origin")
writeError(w, http.StatusForbidden,
"Origin "+origin+" ist nicht erlaubt (Browser-Zugriff nur auf POST /speak von gelisteten Origins in server.allowed_origins)")
"origin "+origin+" is not allowed (browsers may only POST /speak from origins listed in server.allowed_origins)")
})
}
+9 -10
View File
@@ -1,11 +1,10 @@
// Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}; bei Gesetz-
// Verwurf 202 {discarded:"rate_limit"|"quiet_hours"} — Task 05),
// GET /status (inkl. Gesetz-State, mcp/cors — Task 06), POST /cancel,
// POST /mute, POST /unmute. Task 06 ergänzt die CORS-Tür für POST /speak
// (OPTIONS-Preflight + ACAO aus server.allowed_origins, siehe cors.go)
// und den MCP-Endpoint POST /mcp als schlankes eigenes JSON-RPC-Subset
// (initialize, notifications/initialized, tools/list, tools/call mit dem
// speak-Tool — siehe mcp.go). Der Client-Teil ist die CLI-Seite derselben
// Vertragsschnittstelle.
// Package api defines the daemon's HTTP surface on 127.0.0.1: POST /speak
// (enqueue, 202 {id}; 202 {muted:true} when muted; 202
// {discarded:"rate_limit"|"quiet_hours"} when a law discards), GET /status
// (including law state, mcp/cors), POST /cancel, POST /mute, POST /unmute.
// It also provides the CORS door for POST /speak (OPTIONS preflight + ACAO
// from server.allowed_origins, see cors.go) and the MCP endpoint POST /mcp
// as a lean JSON-RPC subset of its own (initialize,
// notifications/initialized, tools/list, tools/call with the speak tool;
// see mcp.go). The client part is the CLI side of the same contract.
package api
+94 -98
View File
@@ -10,33 +10,32 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene,
// schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich
// braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier
// Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei
// dokumentierten Vereinfachungen, die der Transport hergibt:
// MCP endpoint: Streamable HTTP on POST /mcp, a lean implementation of our
// own of the JSON-RPC 2.0 subset that MCP actually needs (deliberately NO
// SDK dependency; the protocol scope here is four methods plus ping).
// Semantics per MCP 2025-06-18, with two documented simplifications the
// transport allows:
//
// - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs
// (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung
// der Initialize-vor-allem-Regel — ein curl kann tools/call direkt
// rufen. MCP-Clients stört das nicht: Sie fahren den Handshake
// freiwillig.
// - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste
// ist statisch). GET /mcp antwortet daher 405 — die vom Streamable-
// HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream.
// - Stateless: every POST is independent; there are no session IDs
// (Mcp-Session-Id is ignored) and therefore no enforcement of the
// initialize-first rule; a curl can call tools/call directly. MCP
// clients do not mind: they run the handshake voluntarily.
// - No server-initiated streams: talkd never pushes (the tool list is
// static). GET /mcp therefore answers 405, the response the Streamable
// HTTP transport specifies for servers without an SSE stream.
//
// Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte
// ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202
// ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht
// mehr Teil von MCP → -32600.
// Responses: requests get 200 application/json (JSON-RPC error objects also
// 200; the HTTP part did succeed); notifications get 202 without a body
// (JSON-RPC 2.0). Batches (arrays) are no longer part of MCP since
// 2025-06-18 and yield -32600.
// mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der
// Client keine angibt (initialize ohne protocolVersion). Angefragte
// Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich
// implementiert ist der oben dokumentierte 2025-06-18-Umfang.
// mcpProtocolVersion is the version talkd itself speaks when the client
// names none (initialize without protocolVersion). Requested versions are
// accepted and echoed back; what is implemented is the 2025-06-18 scope
// documented above.
const mcpProtocolVersion = "2025-06-18"
// JSON-RPC-2.0-Fehlercodes (benutztes Subset).
// JSON-RPC 2.0 error codes (subset in use).
const (
rpcParseError = -32700
rpcInvalidRequest = -32600
@@ -45,12 +44,11 @@ const (
rpcInternalError = -32603
)
// mcpServerName ist der serverInfo-Name im Initialize-Ergebnis.
// mcpServerName is the serverInfo name in the initialize result.
const mcpServerName = "talkd"
// jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als
// RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0
// bzw. id:string unterscheidbar.
// jsonRPCRequest is an incoming JSON-RPC message. ID as RawMessage keeps
// "field missing" (notification) distinguishable from id:0 or id:string.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -58,9 +56,9 @@ type jsonRPCRequest struct {
Params json.RawMessage `json:"params"`
}
// jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus
// Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern
// ohne bekannte ID null (JSON-RPC 2.0).
// jsonRPCResponse is an outgoing JSON-RPC message. Exactly one of
// Result/Error is set. ID is never omitted; null on parse errors without a
// known ID (JSON-RPC 2.0).
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -68,112 +66,112 @@ type jsonRPCResponse struct {
Error *rpcError `json:"error,omitempty"`
}
// rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort.
// rpcError is the error object of a JSON-RPC response.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// mcpInitializeParams sind die relevanten Teile der initialize-Parameter
// (Client-Capabilities interessieren talkd nicht — Tools genügen).
// mcpInitializeParams holds the relevant parts of the initialize params
// (client capabilities do not interest talkd; tools suffice).
type mcpInitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
}
// mcpServerInfo beschreibt den Server im Initialize-Ergebnis.
// mcpServerInfo describes the server in the initialize result.
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet:
// Tools (speak) — sonst nichts.
// mcpCapabilities reports in the initialize result what talkd offers:
// tools (speak), nothing else.
type mcpCapabilities struct {
Tools map[string]any `json:"tools"`
}
// mcpInitializeResult ist das Ergebnis von initialize: die (geechote)
// Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form.
// mcpInitializeResult is the result of initialize: the (echoed) protocol
// version, the capabilities and the server info, in 2025-06-18 form.
type mcpInitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities mcpCapabilities `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
// mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool
// (speak); ohne nextCursor (die Liste passt immer in eine Seite).
// mcpToolsListResult is the result of tools/list: exactly one tool (speak);
// no nextCursor (the list always fits on one page).
type mcpToolsListResult struct {
Tools []mcpTool `json:"tools"`
}
// mcpTool ist ein Tool-Eintrag in tools/list.
// mcpTool is a tool entry in tools/list.
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema mcpSchema `json:"inputSchema"`
}
// mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs.
// mcpSchema is the JSON Schema object of a tool input.
type mcpSchema struct {
Type string `json:"type"`
Properties map[string]mcpProperty `json:"properties"`
Required []string `json:"required"`
}
// mcpProperty ist eine einzelne Schema-Property.
// mcpProperty is a single schema property.
type mcpProperty struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
}
// mcpToolCallParams sind die Parameter von tools/call.
// mcpToolCallParams are the parameters of tools/call.
type mcpToolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text).
// mcpContent is a content block of a tool result (here: text).
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
// mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen)
// auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein
// Protokollfehler (bewusste Entscheidung, siehe speakToolResult).
// mcpToolResult is the result of tools/call. isError:false (omitted) also
// for muted/discarded: that is a RESULT of admission, not a protocol error
// (deliberate decision, see speakToolResult).
type mcpToolResult struct {
Content []mcpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request.
// serveMCP handles POST /mcp: one JSON-RPC message per request.
func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error())
writeRPCError(w, nil, rpcParseError, "reading body: "+err.Error())
return
}
if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") {
writeRPCError(w, nil, rpcInvalidRequest,
"Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST")
"batch requests are not supported (MCP 2025-06-18 removed batches); one message per POST")
return
}
var req jsonRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error())
writeRPCError(w, nil, rpcParseError, "invalid JSON: "+err.Error())
return
}
if req.JSONRPC != "2.0" {
writeRPCError(w, nil, rpcInvalidRequest,
`jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`)
`jsonrpc must be "2.0" (got `+strconv.Quote(req.JSONRPC)+`)`)
return
}
if isNotification(req.ID) {
// Notifications (notifications/initialized, …) bekommen nie eine
// JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte
// Notifications werden still ignoriert (JSON-RPC 2.0).
// Notifications (notifications/initialized, ...) never get a
// JSON-RPC response, only the transport's 202. Unknown
// notifications are silently ignored (JSON-RPC 2.0).
w.WriteHeader(http.StatusAccepted)
return
}
@@ -182,8 +180,8 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
case "initialize":
h.mcpInitialize(w, req)
case "notifications/initialized":
// Mit ID gesendet: kein regulärer Client — die korrekte Antwort
// ist ein leeres Ergebnis, kein Fehler.
// Sent with an ID: not a regular client; the correct answer is an
// empty result, not an error.
writeRPCResult(w, req.ID, struct{}{})
case "ping":
writeRPCResult(w, req.ID, struct{}{})
@@ -193,26 +191,26 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
h.mcpToolsCall(w, req)
default:
writeRPCError(w, req.ID, rpcMethodNotFound,
"Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)")
"method "+strconv.Quote(req.Method)+" is not implemented (available: initialize, notifications/initialized, ping, tools/list, tools/call)")
}
}
// mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine
// server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP-
// Antwort), Tools-Änderungen gibt es nicht.
// mcpGet handles GET /mcp: 405 with Allow. talkd offers no server-initiated
// streams (the Streamable HTTP response for that case); there are no tool
// changes.
func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "POST, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
}
// mcpInitialize behandelt initialize: Die angefragte Protokollversion wird
// geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities:
// Tools; serverInfo: talkd + Daemon-Version.
// mcpInitialize handles initialize: the requested protocol version is
// echoed (accepted); empty means our own (2025-06-18). Capabilities: tools;
// serverInfo: talkd + daemon version.
func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpInitializeParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "initialize params: "+err.Error())
return
}
}
@@ -227,18 +225,18 @@ func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
})
}
// mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen
// Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive
// Mute und Gesetz (Rate-Limit/Ruhezeit).
// mcpToolsCall handles tools/call: exactly one tool (speak) whose arguments
// take the same admission path as POST /speak, including mute and the laws
// (rate limit/quiet hours).
func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call params: "+err.Error())
return
}
if params.Name != "speak" {
writeRPCError(w, req.ID, rpcInvalidParams,
"unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)")
"unknown tool "+strconv.Quote(params.Name)+" (available: speak)")
return
}
@@ -254,8 +252,8 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
adm := h.admitSpeak(speakReq)
if adm.status != http.StatusAccepted {
// Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als
// JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis.
// Validation/internal error (HTTP equivalent 400/503): report as a
// JSON-RPC error; this was not an admission result.
code := rpcInvalidParams
if adm.status != http.StatusBadRequest {
code = rpcInternalError
@@ -266,38 +264,37 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
writeRPCResult(w, req.ID, speakToolResult(adm.resp))
}
// speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem
// Task-06-Brief (text Pflicht; priority/provider/lang/now optional).
// speakTool is the tool description of speak: input schema with text
// required; priority/provider/lang/now optional.
func speakTool() mcpTool {
return mcpTool{
Name: "speak",
Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " +
"Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " +
"Queue, Rate-Limit, Mute und Ruhezeiten gelten.",
Description: "Speaks a short announcement (status, next step, what the user is needed for; " +
"never long answers). Goes through the daemon: queue, rate limit, mute and quiet hours apply.",
InputSchema: mcpSchema{
Type: "object",
Properties: map[string]mcpProperty{
"text": {
Type: "string",
Description: "Der Ansage-Text (1–2 Sätze Etikette)",
Description: "The announcement text (1 to 2 sentences by etiquette)",
},
"priority": {
Type: "string",
Enum: []string{"low", "normal", "high"},
Description: "Abarbeitungs-Priorität (Default normal)",
Description: "Processing priority (default normal)",
},
"provider": {
Type: "string",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); empty = default chain",
},
"lang": {
Type: "string",
Enum: []string{"de", "en"},
Description: "Sprache pin; leer = automatische Erkennung",
Description: "Language pin; empty = automatic detection",
},
"now": {
Type: "boolean",
Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus",
Description: "Jumps ahead of the queue and deliberately bypasses rate limit and quiet hours",
},
},
Required: []string{"text"},
@@ -305,27 +302,26 @@ func speakTool() mcpTool {
}
}
// speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool-
// Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind
// das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler)
// — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme:
// "queued: <id>".
// speakToolResult translates a /speak admission response into the MCP tool
// result. Mute and law discards are deliberately NOT isError: they are the
// correct, documented result of admission (no protocol error); the agent
// should read the reason, not crash. Successful admission: "queued: <id>".
func speakToolResult(resp SpeakResponse) mcpToolResult {
switch {
case resp.Muted:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "muted: Ansage verworfen (Mute ist aktiv)",
Text: "muted: announcement discarded (mute is on)",
}}}
case resp.Discarded == queue.DiscardRateLimit:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)",
Text: "discarded: rate_limit; announcement discarded (priority low over the window budget)",
}}}
case resp.Discarded == queue.DiscardQuietHours:
text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen"
text := "discarded: quiet_hours; quiet hours active, announcement discarded"
if resp.Toast {
text += " (durch Toast ersetzt, kein Audio)"
text += " (replaced by a toast, no audio)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
case resp.Discarded != "":
@@ -336,26 +332,26 @@ func speakToolResult(resp SpeakResponse) mcpToolResult {
default:
text := "queued: " + resp.ID
if resp.Truncated {
text += " (Text auf max_text gekürzt — Ansagen sind 1–2 Sätze, keine Langantworten)"
text += " (text truncated to max_text; announcements are 1 to 2 sentences, not long answers)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
}
}
// isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt
// oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort).
// isNotification reports whether the ID marks a notification (field missing
// or explicitly null; both get no response per JSON-RPC 2.0).
func isNotification(id json.RawMessage) bool {
return len(id) == 0 || strings.TrimSpace(string(id)) == "null"
}
// writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200,
// writeRPCResult writes a successful JSON-RPC response (200,
// application/json).
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result})
}
// writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die
// Anfrage-ID unbekannt ist (Parse-Fehler).
// writeRPCError writes a JSON-RPC error response; id null when the request
// ID is unknown (parse error).
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
writeRPC(w, jsonRPCResponse{
JSONRPC: "2.0",
@@ -364,7 +360,7 @@ func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg stri
})
}
// writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer.
// writeRPC writes the JSON-RPC response to the response writer.
func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
+65 -64
View File
@@ -10,45 +10,45 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
// versehentliche Riesen-Payloads).
// maxBodyBytes limits request bodies (announcements are short; protection
// against accidental huge payloads).
const maxBodyBytes = 64 << 10
// HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
// kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
// injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
// HandlerConfig bundles the dependencies of the HTTP surface. The handler
// knows neither config files nor env; the daemon injects everything, and
// process-boundary tests drive exactly this surface as a black box.
type HandlerConfig struct {
// Queue ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
// Queue is the daemon queue (Enqueue/Cancel/Current/Pending).
Queue *queue.Queue
// Mute ist der persistente Mute-State (Datei mute im Config-Dir).
// Mute is the persistent mute state (file mute in the config dir).
Mute queue.MuteStore
// Version ist die Daemon-Version für GET /status und serverInfo.
// Version is the daemon version for GET /status and serverInfo.
Version string
// KnownProviders sind die gültigen Provider-Namen (Validierung des
// provider-Felds in POST /speak; talkd liefert provider.Names()).
// KnownProviders are the valid provider names (validation of the
// provider field in POST /speak; the daemon passes provider.Names()).
KnownProviders []string
// Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
// Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
// Provider resolves the active provider for GET /status (daemon side:
// pin of the running item or first available chain tier). Optional.
Provider func() string
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
// keine CORS-Header, der Browser blockt; siehe cors.go).
// AllowedOrigins is the CORS allow list for POST /speak (empty = no CORS
// headers, the browser blocks; see cors.go).
AllowedOrigins []string
// Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon
// stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional:
// nil → der Endpunkt antwortet 501.
// Shutdown shuts the daemon down (POST /shutdown, `talk daemon stop`).
// Must not block and must be callable more than once. Optional: nil
// means the endpoint answers 501.
Shutdown func()
}
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404. Vor dem
// Router steht der originGuard (cors.go): Browser-Anfragen (Origin-Header)
// erreichen nur POST /speak von gelisteten Origins; alles andere ist 403.
// NewHandler returns the daemon's HTTP routes. Method binding via ServeMux
// patterns: wrong method 405, unknown path 404. The originGuard (cors.go)
// sits in front of the router: browser requests (Origin header) only reach
// POST /speak from listed origins; everything else is 403.
func NewHandler(cfg HandlerConfig) http.Handler {
h := &handler{
queue: cfg.Queue,
@@ -64,18 +64,18 @@ func NewHandler(cfg HandlerConfig) http.Handler {
}
mux := http.NewServeMux()
mux.HandleFunc("POST /speak", h.speak)
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06)
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS preflight
mux.HandleFunc("GET /status", h.status)
mux.HandleFunc("POST /cancel", h.cancel)
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: no server-initiated streams
return h.originGuard(mux)
}
// handler ist der interne Zustand der HTTP-Oberfläche.
// handler is the internal state of the HTTP surface.
type handler struct {
queue *queue.Queue
mute queue.MuteStore
@@ -86,18 +86,18 @@ type handler struct {
shutdown func()
}
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
// dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz
// (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen —
// die Antwort nennt dann den Grund ({"discarded":"…"}). CORS-Header nur
// hier und nur bei gelisteter Origin (siehe cors.go).
// speak handles POST /speak: validate, discard when muted (202, documented
// behaviour; deliberately not an error so callers need no special
// handling), otherwise enqueue and answer 202 {id}. The laws (rate-limit
// budget, quiet hours) may also discard on admission; the response then
// names the reason ({"discarded":"..."}). CORS headers only here and only
// for a listed origin (see cors.go).
func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
h.setSpeakCors(w, r)
var req SpeakRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
adm := h.admitSpeak(req)
@@ -108,38 +108,38 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
writeJSON(w, adm.status, adm.resp)
}
// speakAdmission ist das Ergebnis des gemeinsamen Aufnahme-Pfads von
// POST /speak und dem MCP-Tool speak: HTTP-Status-Äquivalent plus Body —
// 202 mit {id}/{muted}/{discarded} als Aufnahme-Ergebnis, 400/503 mit
// errMsg als Fehler (MCP übersetzt beides, siehe mcp.go).
// speakAdmission is the result of the shared admission path of POST /speak
// and the MCP tool speak: an HTTP status equivalent plus body. 202 with
// {id}/{muted}/{discarded} as the admission result, 400/503 with errMsg as
// an error (MCP translates both, see mcp.go).
type speakAdmission struct {
status int
resp SpeakResponse
errMsg string
}
// admitSpeak ist der eine Aufnahme-Pfad hinter POST /speak und dem
// MCP-Tool speak (Task 06): Validierung, Mute-Check, Enqueue inklusive
// Gesetz (Rate-Limit-Budget, Ruhezeit) — identische Semantik für beide
// Türen, kein Contract-Drift.
// admitSpeak is the one admission path behind POST /speak and the MCP tool
// speak: validation, mute check, enqueue including the laws (rate-limit
// budget, quiet hours). Identical semantics for both doors, no contract
// drift.
func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
if strings.TrimSpace(req.Text) == "" {
return speakAdmission{status: http.StatusBadRequest, errMsg: "text fehlt oder ist leer"}
return speakAdmission{status: http.StatusBadRequest, errMsg: "text is missing or empty"}
}
if req.Priority == "" {
req.Priority = string(queue.PriorityNormal)
}
if !queue.Priority(req.Priority).Valid() {
return speakAdmission{status: http.StatusBadRequest,
errMsg: fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)}
errMsg: fmt.Sprintf("priority: invalid value %q (allowed: low, normal, high)", req.Priority)}
}
if req.Provider != "" && !h.known[req.Provider] {
return speakAdmission{status: http.StatusBadRequest,
errMsg: fmt.Sprintf("provider: unbekannter Wert %q", req.Provider)}
errMsg: fmt.Sprintf("provider: unknown value %q", req.Provider)}
}
if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
return speakAdmission{status: http.StatusBadRequest,
errMsg: fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang)}
errMsg: fmt.Sprintf("lang: invalid value %q (allowed: de, en)", req.Lang)}
}
if h.mute.Muted() {
@@ -158,18 +158,18 @@ func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()}
}
if adm.Discarded != "" {
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
// Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit
// Aufrufer keine Sonderbehandlung brauchen.
// Law discard (rate-limit budget / quiet hours): documented
// behaviour like mute, 202 with a reason instead of an error, so
// callers need no special handling.
return speakAdmission{status: http.StatusAccepted,
resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}}
}
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID, Truncated: adm.Truncated}}
}
// status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
// Item, Mute-State, aktiver Provider, Version — und seit Task 05 den
// Gesetz-State (Rate-Limit mit nächstem Slot, Ruhezeit-Fenster).
// status handles GET /status: queue length (+ waiting IDs), current item,
// mute state, active provider, version, and the law state (rate limit with
// next slot, quiet-hours window).
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
pending := h.queue.Pending()
ids := make([]string, len(pending))
@@ -201,17 +201,17 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, st)
}
// corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status
// soll "cors": [] zeigen, wenn die Tür zu ist.
// corsList copies the allow list as a JSON [] instead of null: the status
// should show "cors": [] when the door is closed.
func corsList(origins []string) []string {
out := make([]string, len(origins))
copy(out, origins)
return out
}
// lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von
// /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
// frei / Ruhezeit inaktiv).
// lawState converts the queue's law view into the JSON form of /status:
// timestamps only when they lie in the future (null = slot free / quiet
// hours inactive).
func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
now := time.Now()
rl := &RateLimitState{
@@ -234,13 +234,14 @@ func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
return rl, qh
}
// cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
// cancel handles POST /cancel: cancel the running announcement and clear
// the queue.
func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()})
}
// muteEndpoint behandelt POST /mute bzw. POST /unmute: persistenter
// Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
// muteEndpoint handles POST /mute and POST /unmute: persistent mute state
// (file in the config dir, survives daemon restarts).
func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h.mute.Set(muted); err != nil {
@@ -251,26 +252,26 @@ func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
}
}
// shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort
// zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor
// der Server die Verbindung schließt.
// shutdownEndpoint handles POST /shutdown (`talk daemon stop`): response
// first, then the shutdown trigger, so the caller gets its 200 before the
// server closes the connection.
func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) {
if h.shutdown == nil {
writeError(w, http.StatusNotImplemented, "dieser Daemon kennt keinen Shutdown-Trigger")
writeError(w, http.StatusNotImplemented, "this daemon has no shutdown trigger")
return
}
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
h.shutdown()
}
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
// writeJSON writes a JSON response with a status code.
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
// writeError schreibt einen Fehler als {"error": "…"} mit Status-Code.
// writeError writes an error as {"error": "..."} with a status code.
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
+56 -55
View File
@@ -1,53 +1,53 @@
package api
// Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende
// Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben
// Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client.
// Request/response types of the daemon HTTP contract (binding fields). The
// CLI and other consumers (MCP) use the same types: one source, no contract
// drift between daemon and client.
// SpeakRequest ist der Body von POST /speak.
// SpeakRequest is the body of POST /speak.
type SpeakRequest struct {
// Text ist der Ansage-Text (Pflicht).
// Text is the announcement text (required).
Text string `json:"text"`
// Priority: low | normal | high (Default normal, wenn leer).
// Priority: low | normal | high (default normal when empty).
Priority string `json:"priority,omitempty"`
// Provider pinnt die Provider-Kette (--provider).
// Provider pins the provider chain (--provider).
Provider string `json:"provider,omitempty"`
// Voice pinnt eine Voice-ID (--voice).
// Voice pins a voice ID (--voice).
Voice string `json:"voice,omitempty"`
// Lang pinnt die Sprache de | en (leer = automatische Erkennung).
// Lang pins the language de | en (empty = automatic detection).
Lang string `json:"lang,omitempty"`
// Now lässt die Ansage vor allen wartenden springen (--now).
// Now lets the announcement jump ahead of all waiting ones (--now).
Now bool `json:"now,omitempty"`
}
// SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei
// aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes
// Verhalten, kein Fehler). Seit Task 05 meldet verworfenen Spam ebenso
// dokumentiert: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"} —
// plus "toast":true, wenn die Ruhezeit-Ansage durch eine Benachrichtigung
// ersetzt wurde (quiet_mode = "toast").
// SpeakResponse is the response of POST /speak: 202 {"id":"..."}; with mute
// active 202 {"id":"","muted":true} (announcement discarded; documented
// behaviour, not an error). Discarded spam is reported just as
// documented: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"}, plus
// "toast":true when the quiet-hours announcement was replaced by a
// notification (quiet_mode = "toast").
type SpeakResponse struct {
ID string `json:"id"`
Muted bool `json:"muted,omitempty"`
// Discarded ist der Verwurfs-Grund ("rate_limit" | "quiet_hours");
// leer, wenn die Ansage aufgenommen wurde.
// Discarded is the discard reason ("rate_limit" | "quiet_hours"); empty
// when the announcement was admitted.
Discarded string `json:"discarded,omitempty"`
// Toast meldet die Ersetzung durch eine Benachrichtigung.
// Toast reports the replacement by a notification.
Toast bool `json:"toast,omitempty"`
// Truncated meldet, dass der Text auf queue.max_text gekürzt und so
// aufgenommen wurde (Gesetz gegen vorgelesene Langantworten).
// Truncated reports that the text was cut to queue.max_text and
// admitted that way (the law against read-aloud long answers).
Truncated bool `json:"truncated,omitempty"`
}
// CurrentItem beschreibt das laufende Item in GET /status.
// CurrentItem describes the running item in GET /status.
type CurrentItem struct {
ID string `json:"id"`
Text string `json:"text"`
@@ -55,12 +55,12 @@ type CurrentItem struct {
Now bool `json:"now"`
}
// StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles
// Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version.
// QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge
// — die CLI braucht sie für ihr --wait-Polling. Task 05 ergänzt additiv
// den Gesetz-State (rate_limit, quiet_hours); Task 06 additiv mcp/cors
// (Compat-Felder: MCP-Endpoint vorhanden, konfigurierte CORS-Origins).
// StatusResponse is the response of GET /status: queue length, current item
// (null when idle), mute state, active provider, daemon version. QueueIDs
// lists the waiting IDs in processing order; the CLI needs them for its
// --wait polling. The law state (rate_limit, quiet_hours) and mcp/cors
// (compat fields: MCP endpoint present, configured CORS origins) are
// additive.
type StatusResponse struct {
Version string `json:"version"`
Queue int `json:"queue"`
@@ -72,59 +72,60 @@ type StatusResponse struct {
QuietHours *QuietHoursState `json:"quiet_hours,omitempty"`
Limits *LimitsState `json:"limits,omitempty"`
// MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true;
// Feld existiert für additive Compat-Sicht von Clients/Browsern).
// MCP reports that the daemon offers the MCP endpoint (always true; the
// field exists for an additive compat view of clients/browsers).
MCP bool `json:"mcp"`
// Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak
// (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty.
// Cors is the configured CORS allow list of POST /speak (empty [] =
// door closed). Always present (even when empty), not omitempty.
Cors []string `json:"cors"`
}
// RateLimitState ist der Rate-Limit-State in GET /status (Task 05).
// RateLimitState is the rate-limit state in GET /status.
type RateLimitState struct {
// Interval ist der konfigurierte Mindestabstand als Dauer-String
// ("30s"; "0s" = unbegrenzt).
// Interval is the configured minimum spacing as a duration string
// ("30s"; "0s" = unlimited).
Interval string `json:"interval"`
// AllowHighBurst meldet, ob priority=high verstoßen darf.
// AllowHighBurst reports whether priority=high may violate it.
AllowHighBurst bool `json:"allow_high_burst"`
// NextSlot ist der nächste freie Ansage-Slot als RFC3339-Zeitstempel;
// null, wenn sofort gespielt werden dürfte (oder unbegrenzt).
// NextSlot is the next free announcement slot as an RFC3339 timestamp;
// null when playback would be allowed immediately (or unlimited).
NextSlot *string `json:"next_slot"`
}
// LimitsState sind die Längen- und Alters-Gesetze in GET /status.
// LimitsState holds the length and age laws in GET /status.
type LimitsState struct {
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (0 = unbegrenzt).
// MaxText is the upper bound of the announcement length in characters
// (0 = unlimited).
MaxText int `json:"max_text"`
// MaxAge ist die längste Wartezeit in der Queue als Dauer-String
// ("2m0s"; "0s" = unbegrenzt).
// MaxAge is the longest wait in the queue as a duration string ("2m0s";
// "0s" = unlimited).
MaxAge string `json:"max_age"`
}
// QuietHoursState ist der Ruhezeit-State in GET /status (Task 05).
// QuietHoursState is the quiet-hours state in GET /status.
type QuietHoursState struct {
// Window ist das konfigurierte Fenster ("18:00-08:00"; "" = deaktiviert).
// Window is the configured window ("18:00-08:00"; "" = disabled).
Window string `json:"window"`
// Active meldet, ob die lokale Zeit gerade im Fenster liegt.
// Active reports whether local time is currently inside the window.
Active bool `json:"active"`
// Mode ist der konfigurierte Ruhezeit-Modus ("discard" | "toast").
// Mode is the configured quiet-hours mode ("discard" | "toast").
Mode string `json:"mode"`
// Until ist das Fenster-Ende als RFC3339-Zeitstempel, wenn aktiv;
// sonst null.
// Until is the window end as an RFC3339 timestamp when active;
// otherwise null.
Until *string `json:"until"`
}
// PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder
// gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage
// weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in
// beiden Fällen hat der Aufrufer nichts mehr zu warten.
// PendingOrCurrent reports whether the announcement with the ID is still
// waiting or currently playing; the basis of the CLI's --wait polling. If
// it is neither waiting nor playing, it is done (or cancelled); in both
// cases the caller has nothing left to wait for.
func (s StatusResponse) PendingOrCurrent(id string) bool {
if s.Current != nil && s.Current.ID == id {
return true
@@ -137,17 +138,17 @@ func (s StatusResponse) PendingOrCurrent(id string) bool {
return false
}
// CancelResponse ist die Antwort von POST /cancel.
// CancelResponse is the response of POST /cancel.
type CancelResponse struct {
Cancelled int `json:"cancelled"`
}
// MuteResponse ist die Antwort von POST /mute und POST /unmute.
// MuteResponse is the response of POST /mute and POST /unmute.
type MuteResponse struct {
Muted bool `json:"muted"`
}
// ShutdownResponse ist die Antwort von POST /shutdown.
// ShutdownResponse is the response of POST /shutdown.
type ShutdownResponse struct {
OK bool `json:"ok"`
}
+19 -19
View File
@@ -6,28 +6,28 @@ import (
"io"
)
// Sink-Namen (Teil des öffentlichen Vertrags; Spec: audio.sink = "oto"|"null").
// Sink names (part of the public contract; spec: audio.sink = "oto"|"null").
const (
NameOto = "oto"
NameNull = "null"
)
// Sink gibt Audio-Bytes wieder. Die Bytes stammen aus Provider.Synthesize;
// oto versteht RIFF/WAVE (PCM — das Lieferformat von osbuiltin) und MP3
// (das Lieferformat der Cloud-Provider ElevenLabs/OpenAI/Edge).
// Sink plays audio bytes. The bytes come from Provider.Synthesize; oto
// understands RIFF/WAVE (PCM, the delivery format of osbuiltin) and MP3
// (the delivery format of the cloud providers ElevenLabs/OpenAI/Edge).
type Sink interface {
// Play spielt die Daten blockierend ab (bis zum Ende oder bis ctx
// abgebrochen wird).
// Play plays the data and blocks until the end or until ctx is
// cancelled.
Play(ctx context.Context, data []byte) error
// Name liefert den Sink-Namen (audio.sink-Konfiguration).
// Name returns the sink name (audio.sink configuration).
Name() string
}
// NewSink liefert den Sink zum konfigurierten Namen ("oto" oder "null").
// log ist ein optionaler Log-Output — die CLI übergibt ihr stderr, über den
// der null-Sink seine Verwurf-Zeile schreibt (nil = still verwerfen); der
// oto-Sink braucht kein Log. Unbekannte Namen sind ein klarer Fehler.
// NewSink returns the sink for the configured name ("oto" or "null"). log
// is an optional log output: the CLI passes its stderr so the null sink can
// write its discard line (nil = discard silently); the oto sink needs no
// log. Unknown names are a clear error.
func NewSink(name string, log io.Writer) (Sink, error) {
switch name {
case NameOto:
@@ -35,24 +35,24 @@ func NewSink(name string, log io.Writer) (Sink, error) {
case NameNull:
return NullSink{W: log}, nil
default:
return nil, fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull)
return nil, fmt.Errorf("unknown audio sink %q (allowed: %q, %q)", name, NameOto, NameNull)
}
}
// NullSink verwirft jede Ansage ohne Ton — für CI, Server und Tests. Wurde
// ein W gesetzt (NewSink reicht dafür den Log-Output der CLI durch), loggt
// Play eine Zeile darüber, damit „läuft ohne Ton durch" sichtbar bleibt.
// NullSink discards every announcement without sound; for CI, servers and
// tests. If W is set (NewSink passes the CLI's log output), Play logs one
// line about it so "runs through silently" stays visible.
type NullSink struct {
W io.Writer // optionaler Log-Output; nil = still verwerfen
W io.Writer // optional log output; nil = discard silently
}
// Name implementiert Sink.
// Name implements Sink.
func (NullSink) Name() string { return NameNull }
// Play implementiert Sink: verwirft die Daten.
// Play implements Sink: discards the data.
func (n NullSink) Play(ctx context.Context, data []byte) error {
if n.W != nil {
fmt.Fprintf(n.W, "audio: %d Bytes verworfen (null sink)\n", len(data))
fmt.Fprintf(n.W, "audio: %d bytes discarded (null sink)\n", len(data))
}
return nil
}
+39 -39
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
// buildWAV baut nach RIFF-Spezifikation eine WAV-Datei (unabhängig vom
// Parser implementiert, damit Parser und Test-Fixture nicht denselben
// Fehler teilen).
// buildWAV builds a WAV file per the RIFF specification (implemented
// independently of the parser so parser and test fixture cannot share the
// same bug).
func buildWAV(audioFormat uint16, channels, rate, bits int, samples []byte) []byte {
var buf bytes.Buffer
buf.WriteString("RIFF")
@@ -44,22 +44,22 @@ func TestDecodeWAV(t *testing.T) {
t.Fatalf("decodeWAV: %v", err)
}
if f.SampleRate != 22050 || f.Channels != 1 || f.BitsPerCoded != 16 || f.Float {
t.Errorf("Format = %+v", f)
t.Errorf("format = %+v", f)
}
if string(pcm) != "\x01\x02\x03\x04" {
t.Errorf("PCM = %x", pcm)
}
})
t.Run("8-bit stereo mit Extra-Chunk", func(t *testing.T) {
t.Run("8-bit stereo with extra chunk", func(t *testing.T) {
wav := buildWAV(1, 2, 8000, 8, []byte{7, 8, 9})
// LIST-Chunk (word-aligned, ungerade Länge) zwischen fmt und data einfügen
// insert a LIST chunk (word-aligned, odd length) between fmt and data
withList := append(wav[:36], append([]byte("LIST\x03\x00\x00\x00abc\x00"), wav[36:]...)...)
f, pcm, err := decodeWAV(withList)
if err != nil {
t.Fatalf("decodeWAV: %v", err)
}
if f.BitsPerCoded != 8 || f.Channels != 2 {
t.Errorf("Format = %+v", f)
t.Errorf("format = %+v", f)
}
if string(pcm) != "\x07\x08\x09" {
t.Errorf("PCM = %x", pcm)
@@ -72,42 +72,42 @@ func TestDecodeWAV(t *testing.T) {
t.Fatalf("decodeWAV: %v", err)
}
if !f.Float || f.BitsPerCoded != 32 {
t.Errorf("Format = %+v, want Float32", f)
t.Errorf("format = %+v, want Float32", f)
}
})
t.Run("leerer data-Chunk ist erlaubt", func(t *testing.T) {
t.Run("empty data chunk is allowed", func(t *testing.T) {
wav := buildWAV(1, 1, 22050, 16, nil)
_, pcm, err := decodeWAV(wav)
if err != nil {
t.Fatalf("decodeWAV: %v", err)
}
if len(pcm) != 0 {
t.Errorf("PCM = %x, want leer", pcm)
t.Errorf("PCM = %x, want empty", pcm)
}
})
}
func TestDecodeWAVFehler(t *testing.T) {
func TestDecodeWAVErrors(t *testing.T) {
cases := []struct {
name string
data []byte
want string
}{
{"zu kurz", []byte("RIFF"), "zu kurz"},
{"kein riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"},
{"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "nicht unterstütztes Format"},
{"3 kanäle", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "Kanäle"},
{"ohne data", func() []byte {
{"too short", []byte("RIFF"), "too short"},
{"no riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"},
{"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "unsupported format"},
{"3 channels", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "channels"},
{"without data", func() []byte {
wav := buildWAV(1, 1, 22050, 16, []byte{1, 2})
return wav[:36] // nur fmt-Chunk behalten
return wav[:36] // keep only the fmt chunk
}(), "data"},
{"kaputter chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "überragt"},
{"broken chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "exceeds"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := decodeWAV(tc.data)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("decodeWAV-Fehler = %v, will Meldung mit %q", err, tc.want)
t.Errorf("decodeWAV error = %v, want message containing %q", err, tc.want)
}
})
}
@@ -124,9 +124,9 @@ func TestNewSink(t *testing.T) {
}
}
if _, err := NewSink("mpv", nil); err == nil {
t.Error("NewSink(\"mpv\") soll fehlschlagen")
t.Error("NewSink(\"mpv\") should fail")
}
t.Run("null-Sink loggt über den Factory-Writer", func(t *testing.T) {
t.Run("null sink logs via the factory writer", func(t *testing.T) {
var buf bytes.Buffer
s, err := NewSink(NameNull, &buf)
if err != nil {
@@ -135,49 +135,49 @@ func TestNewSink(t *testing.T) {
if err := s.Play(context.Background(), []byte("audio")); err != nil {
t.Fatalf("Play: %v", err)
}
if !strings.Contains(buf.String(), "verworfen (null sink)") {
t.Errorf("Log = %q, will Verwurf-Zeile über den Factory-Writer", buf.String())
if !strings.Contains(buf.String(), "discarded (null sink)") {
t.Errorf("log = %q, want discard line via the factory writer", buf.String())
}
})
}
func TestNullSinkVerwirftUndLoggt(t *testing.T) {
func TestNullSinkDiscardsAndLogs(t *testing.T) {
var buf bytes.Buffer
sink := NullSink{W: &buf}
if err := sink.Play(context.Background(), []byte("audio")); err != nil {
t.Fatalf("NullSink.Play: %v", err)
}
if !strings.Contains(buf.String(), "verworfen") {
t.Errorf("NullSink-Log = %q, will Verwurf-Zeile", buf.String())
if !strings.Contains(buf.String(), "discarded") {
t.Errorf("NullSink log = %q, want discard line", buf.String())
}
if sink.Name() != NameNull {
t.Errorf("Name() = %q", sink.Name())
}
}
// TestOtoSinkUngueltigeDaten: OtoSink.Play muss an der WAV-Dekodierung
// scheitern, bevor irgend ein Audio-Gerät angefasst wird — das ist der
// CI-/Server-sichere Fehlerpfad des oto-Sinks.
func TestOtoSinkUngueltigeDaten(t *testing.T) {
err := (OtoSink{}).Play(context.Background(), []byte("kein audio"))
// TestOtoSinkInvalidData: OtoSink.Play must fail at WAV decoding before any
// audio device is touched; this is the CI/server-safe error path of the oto
// sink.
func TestOtoSinkInvalidData(t *testing.T) {
err := (OtoSink{}).Play(context.Background(), []byte("not audio"))
if err == nil {
t.Fatal("OtoSink.Play mit Müll: kein Fehler")
t.Fatal("OtoSink.Play with garbage: no error")
}
if !strings.Contains(err.Error(), "wav:") {
t.Errorf("Fehler soll aus dem WAV-Parser kommen: %v", err)
t.Errorf("error should come from the WAV parser: %v", err)
}
}
// TestOtoSinkEchteWiedergabe läuft nur mit TALK_TEST_REAL_AUDIO=1 (erzeugt
// echten Ton); Standard-Testläufe bleiben stumm.
func TestOtoSinkEchteWiedergabe(t *testing.T) {
// TestOtoSinkRealPlayback runs only with TALK_TEST_REAL_AUDIO=1 (produces
// real sound); default test runs stay silent.
func TestOtoSinkRealPlayback(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
t.Skip("skipped (-short)")
}
if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" {
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_AUDIO=1 ausführen")
t.Skip("skipped (audible); run with TALK_TEST_REAL_AUDIO=1")
}
// 0,1 s 440-Hz-Sinus, 16 Bit mono 22050 Hz.
// 0.1 s 440 Hz sine, 16 bit mono 22050 Hz.
const rate = 22050
samples := make([]byte, rate/10*2)
for i := 0; i < rate/10; i++ {
+9 -9
View File
@@ -6,11 +6,11 @@ import (
"time"
)
// Check prüft, ob der Sink name einsatzbereit ist, ohne Ton zu erzeugen:
// null ist immer bereit; oto initialisiert den Audio-Kontext (Standard-
// Format 44,1 kHz Stereo 16 Bit) und wartet auf dessen Bereitschaft — so
// fällt „kein Audiogerät" (Server, Container, fehlende PulseAudio/ALSA-
// Bibliothek) in `talk doctor` auf, nicht erst bei der ersten Ansage.
// Check verifies that the sink name is ready without producing sound: null
// is always ready; oto initializes the audio context (default format 44.1
// kHz stereo 16 bit) and waits for it to become ready, so "no audio device"
// (servers, containers, missing PulseAudio/ALSA library) shows up in
// `talk doctor` rather than at the first announcement.
func Check(ctx context.Context, name string) error {
switch name {
case NameNull:
@@ -23,15 +23,15 @@ func Check(ctx context.Context, name string) error {
select {
case <-c.ready:
if err := c.ctx.Err(); err != nil {
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
return fmt.Errorf("oto: initialization failed: %w", err)
}
return nil
case <-ctx.Done():
return fmt.Errorf("oto: Audio-Gerät antwortet nicht (%v)", ctx.Err())
return fmt.Errorf("oto: audio device not responding (%v)", ctx.Err())
case <-time.After(5 * time.Second):
return fmt.Errorf("oto: Audio-Gerät antwortet nicht binnen 5 s")
return fmt.Errorf("oto: audio device not responding within 5 s")
}
default:
return fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull)
return fmt.Errorf("unknown audio sink %q (allowed: %q, %q)", name, NameOto, NameNull)
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
// Package audio implementiert die Audio-Ausgabe von talk über das
// Sink-Interface: Playback embedded via oto (RIFF/WAVE-PCM und MP3, per
// reinem Go-Dekodierer) und ein null-Sink, das verwirft (CI, Server,
// Tests). Auswahl per Config audio.sink = "oto" | "null" (Default oto).
// Package audio implements talk's audio output behind the Sink interface:
// embedded playback via oto (RIFF/WAVE PCM and MP3, via a pure Go decoder)
// and a null sink that discards (CI, servers, tests). Selected via config
// audio.sink = "oto" | "null" (default oto).
package audio
+22 -24
View File
@@ -8,23 +8,21 @@ import (
mpg "github.com/hajimehoshi/go-mp3"
)
// decodeMP3 dekodiert MP3-Daten — das Lieferformat der Cloud-Provider ab
// Task 03 (ElevenLabs, OpenAI TTS, Edge-TTS) — in rohe PCM-Samples für
// oto.
// decodeMP3 decodes MP3 data, the delivery format of the cloud providers
// (ElevenLabs, OpenAI TTS, Edge TTS), into raw PCM samples for oto.
//
// Entscheidung mp3-Dekodierung (Task 03): eingebauter, reiner Go-Dekoder
// github.com/hajimehoshi/go-mp3 v0.3.4 statt WAV/PCM-Anforderung bei den
// APIs. Begründung: (1) Alle drei Cloud-Provider liefern mp3 als ihr
// natives Format; nur OpenAI kennt response_format "wav"/"pcm", ElevenLabs
// und Edge-TTS aber nicht — eine einheitliche mp3-Pipeline hält die Kette
// symmetrisch. (2) Der Dekoder ist nachweislich frei von CGO (kein
// import "C" im Modul), damit bleibt die Cross-Compile-Verpflichtung
// (CGO_ENABLED=0 für alle 6 Targets) unangetastet — verifiziert per
// GOOS/GOARCH-Matrix-Build, siehe Task-03-Report.
// Decision: a built-in pure Go decoder (github.com/hajimehoshi/go-mp3
// v0.3.4) instead of requesting WAV/PCM from the APIs. Reasons: (1) all
// three cloud providers deliver mp3 as their native format; only OpenAI
// knows response_format "wav"/"pcm", ElevenLabs and Edge TTS do not, so a
// single mp3 pipeline keeps the chain symmetric. (2) The decoder is free of
// CGO (no import "C" in the module), which keeps the cross-compile
// commitment (CGO_ENABLED=0 for all 6 targets) intact, verified by a
// GOOS/GOARCH matrix build.
//
// Der Dekoder liefert immer 16-Bit-PCM little-endian mit 2 Kanälen — auch
// für Mono-Quellen (etwa Edge-TTS: 24 kHz mono) wird auf Stereo
// gespiegelt; die Sample-Rate stammt aus dem ersten MP3-Frame.
// The decoder always yields 16-bit little-endian PCM with 2 channels; mono
// sources (such as Edge TTS: 24 kHz mono) are mirrored to stereo. The
// sample rate comes from the first MP3 frame.
func decodeMP3(data []byte) (pcmFormat, []byte, error) {
dec, err := mpg.NewDecoder(bytes.NewReader(data))
if err != nil {
@@ -32,21 +30,21 @@ func decodeMP3(data []byte) (pcmFormat, []byte, error) {
}
pcm, err := io.ReadAll(dec)
if err != nil {
return pcmFormat{}, nil, fmt.Errorf("mp3: dekodieren: %w", err)
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding: %w", err)
}
if len(pcm) == 0 {
return pcmFormat{}, nil, fmt.Errorf("mp3: Dekodierung lieferte keine Samples")
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding produced no samples")
}
return pcmFormat{
SampleRate: dec.SampleRate(),
Channels: 2, // go-mp3 dekodiert grundsätzlich 2 Kanäle (s. doc)
Channels: 2, // go-mp3 always decodes to 2 channels (see its docs)
BitsPerCoded: 16,
}, pcm, nil
}
// looksLikeMP3 meldet, ob data nach MP3 aussieht: ID3v2-Tag (führt mit
// "ID3") oder ein roher Frame-Sync (erstes Byte 0xFF, oberste 3 Bits des
// zweiten Bytes gesetzt).
// looksLikeMP3 reports whether data looks like MP3: an ID3v2 tag (leading
// "ID3") or a raw frame sync (first byte 0xFF, top 3 bits of the second
// byte set).
func looksLikeMP3(data []byte) bool {
if len(data) >= 3 && string(data[0:3]) == "ID3" {
return true
@@ -54,9 +52,9 @@ func looksLikeMP3(data []byte) bool {
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
}
// decodeAudio ist die Format-Weiche der Sinks: RIFF/WAVE (PCM) nach
// decodeWAV, MP3 (ID3-Tag oder Frame-Sync) nach decodeMP3. Alles andere
// fällt durch zu decodeWAV, das den präzisen „kein RIFF"-Fehler liefert.
// decodeAudio is the format switch of the sinks: RIFF/WAVE (PCM) goes to
// decodeWAV, MP3 (ID3 tag or frame sync) to decodeMP3. Everything else
// falls through to decodeWAV, which yields the precise "no RIFF" error.
func decodeAudio(data []byte) (pcmFormat, []byte, error) {
if len(data) >= 4 && string(data[0:4]) == "RIFF" {
return decodeWAV(data)
+40 -44
View File
@@ -6,16 +6,15 @@ import (
"testing"
)
// Zwei einmalig erzeugte, echte MP3-Fixtures (ffmpeg am Dev-Host, in den
// Test eingebettet — Tests bleiben zur Laufzeit frei von Werkzeug-Abhängig-
// keiten). Beide entsprechen dem Lieferformat der Cloud-Provider aus
// Task 03:
// Two real MP3 fixtures generated once (ffmpeg on the dev host, embedded in
// the test so tests stay free of tool dependencies at runtime). Both match
// the delivery format of the cloud providers:
//
// fixtureMP3Mono: 0,15 s Stille, 24 kHz mono 48 kbit/s — exakt das
// Edge-TTS-Ausgabeformat (audio-24khz-48kbitrate-mono-
// mp3); übt den Mono→Stereo-Upsample-Pfad des Dekoders.
// fixtureMP3Stereo: 0,15 s 440-Hz-Sinus, 44,1 kHz stereo 64 kbit/s — das
// typische ElevenLabs/OpenAI-Format.
// fixtureMP3Mono: 0.15 s silence, 24 kHz mono 48 kbit/s; exactly the
// Edge TTS output format (audio-24khz-48kbitrate-mono-
// mp3); exercises the decoder's mono-to-stereo path.
// fixtureMP3Stereo: 0.15 s 440 Hz sine, 44.1 kHz stereo 64 kbit/s; the
// typical ElevenLabs/OpenAI format.
const fixtureMP3MonoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAACQAABdAAOTk5OTk5OTk5OTlSUlJSUlJSUlJSUmtra2tra2tra2trhISEhISEhISEhIScnJycnJycnJycnLW1tbW1tbW1tbW1zs7Ozs7Ozs7Ozs7n5+fn5+fn5+fn5///////////////AAAAAExhdmM2MC4zMQAAAAAAAAAAAAAAACQD8AAAAAAAAAXQx638BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//NkxAAAAANIAAAAAExBTUVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"
const fixtureMP3StereoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAHAAAGhgA/Pz8/Pz8/Pz8/Pz8/P19fX19fX19fX19fX19ff39/f39/f39/f39/f3+fn5+fn5+fn5+fn5+fn5+/v7+/v7+/v7+/v7+/v9/f39/f39/f39/f39/f//////////////////8AAAAATGF2YzYwLjMxAAAAAAAAAAAAAAAAJANpAAAAAAAABoYxZN5VAAAAAAAAAAAAAAAAAAAAAP/7UGQAAAGPB1PVMCAMAAANIKAAAQtkrUe5tQAAAAA0gwAAAAAAWLlbSAEsSzOM4EAEAaEx+7b9wfPggCAIawf1AgCByXB8P4gBCDju/o8/ynn+U9/QAAB54AwKBwMBwMBQAAFQYGzUQALpOGBjRoAS+wRiXRL4oTPxwxscc7rrbANAE+BRFYRX4CwgQbQbf8QQFYCkKoNv/iJIh8KxCPf/xVIh8PiEej3+oGhKEga/6AAAApR2UEOC4IEAFJQRgMbD4SaWFOYlBOYKASCQCLL/+1JkD4jxxgdL53QADAAADSDgAAEGsCEUDftiQAAANIAAAARAoAmvdKzM8GvTr7RI79DR2VcAU20f7f//oSHMQEjMz03SSMSce03/PSzfLHvMSwKQ4NnM9RTNDwzV1MZFVqS/AHAT+e8a7q9+L/6U/+z6FQAAwGRkDDijFFTKrjabzCXGENGzGA0TxejCIBdNEYs6apxxagJ1trIcXd1/JeZ/X/b/1+i936xuCQSSghxU5gAd/mCwEoZaKkBlfBKGC2BKeoGIwBGAMJiS/APLqv/7UmQuiPG2B8bLXsiQAAANIAAAAQZcHyVMewJQAAA0gAAABHKvo6//9u//q/7Nv1ogDEBcxUkMiRzSZwwMIINMWLQbzFZAgEwK8CEMwXMKCNMZNvIM+GXrLrPfy/0dBBK8xQOM5NTeoQxNRxzhW4hOCMckxOgmjhWUztHMwPjL30xUZXRK8Ac+5n3dP8U+jp/993R/1QAAABhG0CAAXeBgIBQM7kwRQ5jJeegMk8OAwPALy4SxQUY1PL0xYN871ZmKO7X/s/1f///oT4MeHNco//tSZFAN8XoIxYt/0JAAAA0gAAABBrQhFA37YkAAADSAAAAEPXrMRAXg3c72DdEF8MRUH8+VI2Do0rU0WoxhJl9JmCf/1f//////SgAAcCBADChEw0eMWPzNYwwlh3TRv77NE4dMwhQeDIZBxRxGnjoAsl+zwbtQl9P2Vywc//29RAABYoMCCKFka6wsBjBQuObW04mcTGIbCA2hPQBoB2V2wIYzS/1q///6k////+0AAD2/T7fb/b0YCAAACXCJpXpktZdKRqMMiYljiZYRljn/+1JkdA3xqAdI6z7AmAAADSAAAAEFtCEWDXtCQAAANIAAAAS1qIWFQOGBu1BDXyALuvctJN0DKUQSBydaGrcUrx5+AghClCpXselWdBCLUOQJbMQULhJKl5UtMt63+FjfdbQTuGrApeyZav7/e/////+LP9DMeiUThj/Ihg0EmkMQAAHEiVkSKOHEQCE4SASTkRCVR6IniVx4jOxjwVce/EU7vK1MQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/7UmSZAPGrB8ZLfsiQAAANIAAAAQXUHTWVwAAwAAA0goAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//tSZL2AA+cuVO5nIAQAAA0gwAAABJgm3HxggAAAADSDgAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU="
@@ -24,14 +23,14 @@ func fixture(t *testing.T, b64 string) []byte {
t.Helper()
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("Fixture dekodieren: %v", err)
t.Fatalf("decoding fixture: %v", err)
}
return data
}
// TestDecodeMP3Mono: das Edge-Format (24 kHz mono) dekodiert zu 16-Bit-PCM
// stereo — der Dekoder spiegelt Mono auf zwei Kanäle, Samples sind 4 Bytes
// (2 Kanäle × 2 Bytes) aligned.
// TestDecodeMP3Mono: the Edge format (24 kHz mono) decodes to 16-bit PCM
// stereo; the decoder mirrors mono onto two channels, samples are aligned
// to 4 bytes (2 channels x 2 bytes).
func TestDecodeMP3Mono(t *testing.T) {
f, pcm, err := decodeMP3(fixture(t, fixtureMP3MonoB64))
if err != nil {
@@ -41,60 +40,57 @@ func TestDecodeMP3Mono(t *testing.T) {
t.Errorf("SampleRate = %d, want 24000", f.SampleRate)
}
if f.Channels != 2 || f.BitsPerCoded != 16 || f.Float {
t.Errorf("Format = %+v, want 2 Kanäle/16 Bit/int", f)
t.Errorf("format = %+v, want 2 channels/16 bit/int", f)
}
if len(pcm) == 0 || len(pcm)%4 != 0 {
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar (16-Bit-Samples stereo)", len(pcm))
t.Errorf("PCM length = %d, want > 0 and divisible by 4 (16-bit stereo samples)", len(pcm))
}
}
// TestDecodeMP3Stereo: ElevenLabs/OpenAI-typisches Format (44,1 kHz stereo).
// TestDecodeMP3Stereo: the ElevenLabs/OpenAI-typical format (44.1 kHz stereo).
func TestDecodeMP3Stereo(t *testing.T) {
f, pcm, err := decodeMP3(fixture(t, fixtureMP3StereoB64))
if err != nil {
t.Fatalf("decodeMP3 (stereo): %v", err)
}
if f.SampleRate != 44100 || f.Channels != 2 || f.BitsPerCoded != 16 {
t.Errorf("Format = %+v, want 44100 Hz stereo 16 Bit", f)
t.Errorf("format = %+v, want 44100 Hz stereo 16 bit", f)
}
if len(pcm) == 0 || len(pcm)%4 != 0 {
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar", len(pcm))
t.Errorf("PCM length = %d, want > 0 and divisible by 4", len(pcm))
}
}
// TestDecodeMP3Fehler: Daten, die nach MP3 aussehen, aber keine Frames
// enthalten, sind ein klarer mp3-Fehler (nicht der WAV-Fehlerpfad).
func TestDecodeMP3Fehler(t *testing.T) {
cases := []struct{ name, want string }{
{"Müll mit Frame-Sync", "mp3:"},
{"ID3-Tag ohne Frames", "mp3:"},
// TestDecodeMP3Errors: data that looks like MP3 but contains no frames is a
// clear mp3 error (not the WAV error path).
func TestDecodeMP3Errors(t *testing.T) {
cases := []struct {
name string
data []byte
want string
}{
{"garbage with frame sync", []byte{0xFF, 0xFB, 'n', 'o', 't', ' ', 'm', 'p', '3'}, "mp3:"},
{"ID3 tag without frames", []byte("ID3\x04\x00\x00\x00\x00\x00\x00"), "mp3:"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var data []byte
switch tc.name {
case "Müll mit Frame-Sync":
data = []byte{0xFF, 0xFB, 'k', 'e', 'i', 'n', ' ', 'm', 'p', '3'}
case "ID3-Tag ohne Frames":
data = []byte("ID3\x04\x00\x00\x00\x00\x00\x00")
}
_, _, err := decodeMP3(data)
_, _, err := decodeMP3(tc.data)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("decodeMP3-Fehler = %v, will Meldung mit %q", err, tc.want)
t.Errorf("decodeMP3 error = %v, want message containing %q", err, tc.want)
}
})
}
}
// TestLooksLikeMP3 pinnt die Format-Erkennung.
// TestLooksLikeMP3 pins the format detection.
func TestLooksLikeMP3(t *testing.T) {
yes := [][]byte{
fixture(t, fixtureMP3StereoB64), // ID3-Tag
{0xFF, 0xFB, 0x00, 0x00}, // roher Frame-Sync
fixture(t, fixtureMP3StereoB64), // ID3 tag
{0xFF, 0xFB, 0x00, 0x00}, // raw frame sync
}
no := [][]byte{
[]byte("RIFF....WAVE"),
[]byte("kein audio"),
[]byte("not audio"),
{0xFF},
{},
}
@@ -110,18 +106,18 @@ func TestLooksLikeMP3(t *testing.T) {
}
}
// TestDecodeAudioWeiche: RIFF geht an den WAV-Parser, MP3 an den MP3-
// Dekoder, alles andere fällt mit dem WAV-Fehler durch.
func TestDecodeAudioWeiche(t *testing.T) {
// TestDecodeAudioSwitch: RIFF goes to the WAV parser, MP3 to the MP3
// decoder, everything else falls through with the WAV error.
func TestDecodeAudioSwitch(t *testing.T) {
f, _, err := decodeAudio(buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}))
if err != nil || f.SampleRate != 22050 {
t.Errorf("decodeAudio(WAV) = %+v, %v — soll den WAV-Pfad nehmen", f, err)
t.Errorf("decodeAudio(WAV) = %+v, %v; should take the WAV path", f, err)
}
f, _, err = decodeAudio(fixture(t, fixtureMP3MonoB64))
if err != nil || f.SampleRate != 24000 {
t.Errorf("decodeAudio(MP3) = %+v, %v — soll den MP3-Pfad nehmen", f, err)
t.Errorf("decodeAudio(MP3) = %+v, %v; should take the MP3 path", f, err)
}
if _, _, err = decodeAudio([]byte("kein audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
t.Errorf("decodeAudio(Müll) = %v, will WAV-Fehler", err)
if _, _, err = decodeAudio([]byte("not audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
t.Errorf("decodeAudio(garbage) = %v, want WAV error", err)
}
}
+16 -19
View File
@@ -10,20 +10,18 @@ import (
"github.com/ebitengine/oto/v3"
)
// OtoSink spielt Audio embedded über oto (github.com/ebitengine/oto/v3,
// gepinnt auf v3.5.0) ab — kein externer Player nötig. oto lädt die
// Audiotreiber zur Laufzeit ohne CGO (purego), dadurch bleibt talk
// cross-compilierbar.
// OtoSink plays audio embedded via oto (github.com/ebitengine/oto/v3,
// pinned to v3.5.0); no external player needed. oto loads the audio drivers
// at runtime without CGO (purego), which keeps talk cross-compilable.
type OtoSink struct{}
// Name implementiert Sink.
// Name implements Sink.
func (OtoSink) Name() string { return NameOto }
// contextCache hält je PCM-Format einen oto-Kontext vor: oto unterstützt
// nur EINEN Kontext pro Prozess ("Creating multiple contexts is NOT
// supported"), und ein Kontext ist an genau ein Format gebunden. Die CLI
// spielt pro Prozess einmal (Task 02); der Daemon (Task 04) profitiert vom
// Cache.
// contextCache keeps one oto context per PCM format: oto supports only ONE
// context per process ("Creating multiple contexts is NOT supported"), and
// a context is bound to exactly one format. The CLI plays once per process;
// the daemon benefits from the cache.
var contextCache = struct {
sync.Mutex
byFormat map[pcmFormat]*otoContext
@@ -34,7 +32,7 @@ type otoContext struct {
ready chan struct{}
}
// otoFormat mappt ein PCM-Format auf das oto-Format.
// otoFormat maps a PCM format to the oto format.
func (f pcmFormat) otoFormat() oto.Format {
switch {
case f.Float:
@@ -46,7 +44,7 @@ func (f pcmFormat) otoFormat() oto.Format {
}
}
// contextFor liefert den (gecachten) oto-Kontext für ein Format.
// contextFor returns the (cached) oto context for a format.
func contextFor(f pcmFormat) (*otoContext, error) {
contextCache.Lock()
defer contextCache.Unlock()
@@ -60,17 +58,16 @@ func contextFor(f pcmFormat) (*otoContext, error) {
}
ctx, ready, err := oto.NewContext(op)
if err != nil {
return nil, fmt.Errorf("oto: Kontext (Audio-Gerät?) fehlgeschlagen: %w", err)
return nil, fmt.Errorf("oto: creating context failed (no audio device?): %w", err)
}
c := &otoContext{ctx: ctx, ready: ready}
contextCache.byFormat[f] = c
return c, nil
}
// Play implementiert Sink: dekodiert die Daten (RIFF/WAVE-PCM von osbuiltin
// oder MP3 von den Cloud-Providern, siehe mp3.go) und spielt die Samples
// blockierend ab. Fehler des Audio-Geräts oder des Formats werden klar
// gemeldet.
// Play implements Sink: decodes the data (RIFF/WAVE PCM from osbuiltin or
// MP3 from the cloud providers, see mp3.go) and plays the samples, blocking.
// Audio device and format errors are reported clearly.
func (OtoSink) Play(ctx context.Context, data []byte) error {
f, pcm, err := decodeAudio(data)
if err != nil {
@@ -83,7 +80,7 @@ func (OtoSink) Play(ctx context.Context, data []byte) error {
select {
case <-c.ready:
if err := c.ctx.Err(); err != nil {
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
return fmt.Errorf("oto: initialization failed: %w", err)
}
case <-ctx.Done():
return ctx.Err()
@@ -98,7 +95,7 @@ func (OtoSink) Play(ctx context.Context, data []byte) error {
return err
}
if err := player.Err(); err != nil {
return fmt.Errorf("oto: Wiedergabe fehlgeschlagen: %w", err)
return fmt.Errorf("oto: playback failed: %w", err)
}
time.Sleep(5 * time.Millisecond)
}
+17 -18
View File
@@ -5,28 +5,27 @@ import (
"fmt"
)
// pcmFormat beschreibt rohe PCM-Samples, wie sie oto zur Wiedergabe
// braucht.
// pcmFormat describes raw PCM samples as oto needs them for playback.
type pcmFormat struct {
SampleRate int
Channels int
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (nur IEEE float)
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (IEEE float only)
Float bool
}
// decodeWAV zerlegt RIFF/WAVE-Bytes in Format und PCM-Daten.
// decodeWAV splits RIFF/WAVE bytes into format and PCM data.
//
// Unterstützt: PCM (AudioFormat 1) mit 8/16 Bit sowie IEEE-Float
// (AudioFormat 3) mit 32 Bit; Mono und Stereo. Alles andere — erst recht
// komprimierte Formate — ist ein klarer Fehler; die OS-TTS-Wege von
// osbuiltin liefern ausschließlich 16-Bit-PCM-WAV.
// Supported: PCM (AudioFormat 1) with 8/16 bit and IEEE float (AudioFormat
// 3) with 32 bit; mono and stereo. Everything else, compressed formats in
// particular, is a clear error; the OS TTS paths of osbuiltin deliver
// 16-bit PCM WAV exclusively.
func decodeWAV(data []byte) (pcmFormat, []byte, error) {
var f pcmFormat
if len(data) < 12 {
return f, nil, fmt.Errorf("wav: zu kurz für RIFF-Header (%d Bytes)", len(data))
return f, nil, fmt.Errorf("wav: too short for a RIFF header (%d bytes)", len(data))
}
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return f, nil, fmt.Errorf("wav: kein RIFF/WAVE-Header")
return f, nil, fmt.Errorf("wav: no RIFF/WAVE header")
}
var haveFmt bool
@@ -37,12 +36,12 @@ func decodeWAV(data []byte) (pcmFormat, []byte, error) {
size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8]))
body := pos + 8
if body+size > len(data) {
return f, nil, fmt.Errorf("wav: Chunk %q überragt die Datei (Size %d)", id, size)
return f, nil, fmt.Errorf("wav: chunk %q exceeds the data (size %d)", id, size)
}
switch id {
case "fmt ":
if size < 16 {
return f, nil, fmt.Errorf("wav: fmt-Chunk zu kurz (%d)", size)
return f, nil, fmt.Errorf("wav: fmt chunk too short (%d)", size)
}
audioFormat := binary.LittleEndian.Uint16(data[body : body+2])
f.Channels = int(binary.LittleEndian.Uint16(data[body+2 : body+4]))
@@ -53,29 +52,29 @@ func decodeWAV(data []byte) (pcmFormat, []byte, error) {
case audioFormat == 3 && f.BitsPerCoded == 32:
f.Float = true
default:
return f, nil, fmt.Errorf("wav: nicht unterstütztes Format (AudioFormat %d, %d Bit) — erlaubt: PCM 8/16 Bit, Float 32 Bit",
return f, nil, fmt.Errorf("wav: unsupported format (AudioFormat %d, %d bit); allowed: PCM 8/16 bit, float 32 bit",
audioFormat, f.BitsPerCoded)
}
if f.Channels < 1 || f.Channels > 2 {
return f, nil, fmt.Errorf("wav: %d Kanäle (erlaubt: 1, 2)", f.Channels)
return f, nil, fmt.Errorf("wav: %d channels (allowed: 1, 2)", f.Channels)
}
if f.SampleRate <= 0 {
return f, nil, fmt.Errorf("wav: SampleRate %d", f.SampleRate)
return f, nil, fmt.Errorf("wav: sample rate %d", f.SampleRate)
}
haveFmt = true
case "data":
payload = data[body : body+size]
}
pos = body + size
if size%2 == 1 { // Chunks sind word-aligned
if size%2 == 1 { // chunks are word-aligned
pos++
}
}
if !haveFmt {
return f, nil, fmt.Errorf("wav: kein fmt-Chunk")
return f, nil, fmt.Errorf("wav: no fmt chunk")
}
if payload == nil {
return f, nil, fmt.Errorf("wav: kein data-Chunk")
return f, nil, fmt.Errorf("wav: no data chunk")
}
return f, payload, nil
}
+116 -116
View File
@@ -14,183 +14,179 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
// „Audio-Sink wählbar inkl. null").
// Allowed values for audio.sink (public contract).
const (
SinkOto = "oto"
SinkNull = "null"
)
// DefaultServerPort ist der Default-Port des Daemon-HTTP-Servers auf
// 127.0.0.1 (Task-04-Brief: „Port Default 7800").
// DefaultServerPort is the daemon's default HTTP port on 127.0.0.1.
const DefaultServerPort = 7800
// DefaultRateLimit ist das Daemon-Gesetz gegen Spam (Task-05-Brief):
// Default 1 Ansage / 30 s. „0s" schaltet das Limit ab.
// DefaultRateLimit is the anti-spam law's default: one announcement per
// 30 seconds. "0s" disables the limit.
const DefaultRateLimit = 30 * time.Second
// DefaultMaxText ist die Default-Obergrenze der Ansage-Länge in Zeichen —
// etwa zwei bis drei Sätze, rund 25 Sekunden Sprache. 0 = unbegrenzt.
// DefaultMaxText is the default cap on announcement length in characters:
// two to three sentences, roughly 25 seconds of speech. 0 = unlimited.
const DefaultMaxText = 400
// DefaultMaxAge ist die Default-Höchstwartezeit einer Ansage in der Queue;
// was länger parkt, ist veraltet und wird verworfen. „0s" = unbegrenzt.
// DefaultMaxAge is the default longest wait in the queue; whatever parks
// longer is stale and gets discarded. "0s" = unlimited.
const DefaultMaxAge = 2 * time.Minute
// Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02
// führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte
// und das Voice-Mapping; Task 04 den Daemon-Port (server.port, TALK_PORT);
// Task 05 die Anti-Spam-Gesetze (queue.rate_limit, queue.allow_high_burst,
// quiet_hours, quiet_mode); spätere Tasks ergänzen weitere Felder, ohne die
// Auflösungs-Reihenfolge zu ändern.
// DefaultLang is the language assumed when detection cannot decide (empty,
// numbers, ties). Also the language of talk's own announcements such as the
// wrap result when the label gives no hint.
const DefaultLang = "en"
// Config is the effective configuration after all layers were applied.
type Config struct {
Audio Audio `toml:"audio"`
// Server steuert den Daemon-HTTP-Server (talkd) auf 127.0.0.1.
// Server configures the daemon's HTTP server on 127.0.0.1.
Server Server `toml:"server"`
// Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05).
// Queue holds the daemon's anti-spam laws.
Queue Queue `toml:"queue"`
// Daemon steuert den Hintergrundprozess (Autostart).
// Daemon configures the background process (autostart).
Daemon Daemon `toml:"daemon"`
// Provider pinnt die Kette auf genau einen Provider (z. B.
// `provider = "elevenlabs"`); leer = komplette Default-Kette
// (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider
// schlägt diesen Wert. Gültige Namen prüft die Provider-Schicht.
// Lang is the fallback language (de | en) when detection cannot decide,
// and the language of talk's own announcements. Default "en"; env
// TALK_LANG.
Lang string `toml:"lang"`
// Provider pins the chain to exactly one provider (for example
// `provider = "elevenlabs"`); empty = the full default chain
// (elevenlabs → openai → edge → osbuiltin). The --provider flag beats
// this value. Valid names are checked by the provider layer.
Provider string `toml:"provider"`
// Providers enthält per Provider-Einstellungen (Endpunkt, Modell) —
// TOML `[providers.elevenlabs] endpoint = "…"`. Key `providers`
// (Mehrzahl), damit der Pin ( singular `provider`) und die
// Einstellungs-Tabelle in TOML nebeneinander existieren können.
// Providers holds per-provider settings (endpoint, model):
// `[providers.elevenlabs] endpoint = "…"`. The key is plural so the pin
// (`provider`) and the settings table can coexist in TOML.
Providers map[string]ProviderSettings `toml:"providers"`
// Voice ist das Voice-Mapping `voice.<provider>.<lang> = id` — z. B.
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. Schlägt die
// eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles.
// Voice is the voice mapping `voice.<provider>.<lang> = id`, for example
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. It beats the built-in
// neutral defaults; the --voice flag beats everything.
Voice map[string]map[string]string `toml:"voice"`
// Wrap steuert die Befehlsummwicklung `talk wrap` (Task 07).
// Wrap configures `talk wrap`.
Wrap Wrap `toml:"wrap"`
// QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit
// (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert.
// Bewusst kein Default-Fenster: Ein aktives Gesetz, das nach Uhrzeit
// verwirft, würde jeden Cron/Test in den Abendstunden stilllegen —
// Ruhezeiten sind Opt-in wie Mute.
// QuietHours is the quiet window "HH:MM-HH:MM" in local time (may span
// midnight, for example "18:00-08:00"); empty = disabled. Deliberately
// no default window: a law that discards by time of day would silence
// every evening cron job; quiet hours are opt-in like mute.
QuietHours string `toml:"quiet_hours"`
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" (Default,
// still verwerfen) oder "toast" (Benachrichtigung statt Audio).
// QuietMode is the behaviour during quiet hours: "discard" (default,
// drop silently) or "toast" (desktop notification instead of audio).
QuietMode string `toml:"quiet_mode"`
}
// Audio steuert die Audio-Ausgabe.
// Audio configures playback.
type Audio struct {
// Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder
// "null" (verwirft — CI, Server, Tests).
// Sink selects playback: "oto" (embedded playback, default) or "null"
// (discard; CI, servers, tests).
Sink string `toml:"sink"`
}
// Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding
// auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port).
// Server holds the daemon's network settings (loopback only; the daemon
// binds 127.0.0.1, the config only supplies the port).
type Server struct {
// Port des Daemon-HTTP-Servers auf 127.0.0.1 — config
// `[server] port = 7800`, Env-Override TALK_PORT, Default 7800.
// Port of the daemon's HTTP server on 127.0.0.1: `[server] port = 7800`,
// env override TALK_PORT, default 7800.
Port int `toml:"port"`
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06):
// exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*"
// erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne
// Einträge sendet der Daemon gar keine CORS-Header und der Browser
// blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert
// wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert).
// AllowedOrigins is the CORS allow-list for POST /speak: exact origins
// such as "https://chat.example.com". The entry "*" allows every origin
// explicitly. Deliberate default: EMPTY, meaning no CORS headers at all
// and the server-side origin guard rejects browser requests; the door
// stays shut until configured (env TALK_ALLOWED_ORIGINS, comma-separated;
// "off" clears it).
AllowedOrigins []string `toml:"allowed_origins"`
}
// Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`).
// Daemon holds the background process settings (TOML `[daemon]`).
type Daemon struct {
// Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im
// Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für
// Setups, die den Daemon selbst verwalten (systemd, launchd, Task
// Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis.
// Autostart lets talk start the daemon itself in the background on the
// first speak command (default true; env TALK_AUTOSTART). false is for
// setups that manage the daemon themselves (systemd, launchd, Task
// Scheduler): without a daemon, say then speaks directly, with a note.
Autostart bool `toml:"autostart"`
}
// Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`).
// Wrap holds the `talk wrap` settings (TOML `[wrap]`).
type Wrap struct {
// Label ersetzt den Basisnamen des Befehls in der Ansage — z. B.
// `label = "Deployment"` für „Deployment erfolgreich. Dauer: …"
// statt „terraform erfolgreich. Dauer: …" (Env-Override
// TALK_WRAP_LABEL schlägt die Datei).
// Label replaces the command's base name in the announcement, for
// example `label = "Deployment"` to hear "Deployment succeeded" instead
// of "terraform succeeded" (env override TALK_WRAP_LABEL).
Label string `toml:"label"`
}
// Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`).
// Queue holds the daemon's anti-spam laws (TOML `[queue]`).
type Queue struct {
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten als
// Dauer-String (z. B. "30s"; "0s" = unbegrenzt). Default: 30 s.
// RateLimit is the minimum gap between two announcement starts, as a
// duration string ("30s"; "0s" = unlimited). Default 30 s.
RateLimit time.Duration `toml:"rate_limit"`
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
// verstoßen (Default true). Die Ruhezeit gilt davon unberührt.
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Quiet hours still apply.
AllowHighBurst bool `toml:"allow_high_burst"`
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen; längere
// Texte werden an einer Wortgrenze gekürzt (Default 400; 0 =
// unbegrenzt; Env TALK_MAX_TEXT).
// MaxText caps announcement length in characters; longer texts are cut
// at a word boundary (default 400; 0 = unlimited; env TALK_MAX_TEXT).
MaxText int `toml:"max_text"`
// MaxAge ist die längste Wartezeit einer Ansage in der Queue; ältere
// werden beim Fälligwerden verworfen (Default "2m"; "0s" = unbegrenzt;
// Env TALK_MAX_AGE).
// MaxAge is the longest an announcement may wait in the queue; older
// ones are discarded when they come due (default "2m"; "0s" =
// unlimited; env TALK_MAX_AGE).
MaxAge time.Duration `toml:"max_age"`
}
// ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
// Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
// Env-Overrides TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT /
// TALK_EDGE_ENDPOINT (ausgewertet in der Provider-Schicht) schlagen die
// Datei — Keys laufen ausschließlich über TALK_ELEVENLABS_KEY bzw.
// TALK_OPENAI_KEY und werden nie in die Datei geschrieben.
// ProviderSettings are the per-provider endpoint and model (TOML
// `[providers.<name>]`). Empty = built-in defaults; the env overrides
// TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / TALK_EDGE_ENDPOINT
// (read by the provider layer) beat the file. Keys come exclusively from
// TALK_ELEVENLABS_KEY / TALK_OPENAI_KEY and are never written to the file.
type ProviderSettings struct {
Endpoint string `toml:"endpoint"`
Model string `toml:"model"` // nur openai (Default "tts-1")
Model string `toml:"model"` // openai only (default "tts-1")
}
// Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht
// der Auflösung).
// Defaults returns the built-in default configuration (the lowest layer).
func Defaults() Config {
return Config{
Audio: Audio{Sink: SinkOto},
Server: Server{Port: DefaultServerPort},
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
Daemon: Daemon{Autostart: true},
Lang: DefaultLang,
QuietMode: queue.QuietModeDiscard,
}
}
// Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests
// ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen.
// Options controls Load. Every field is injectable so unit tests run without
// a real HOME, environment or files.
type Options struct {
// File überschreibt den platform-nativen Pfad der config.toml (Tests
// injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml".
// File overrides the platform-native path of config.toml (tests inject
// a temp file). Empty: Dir() + "config.toml".
File string
// Getenv liefert Umgebungsvariablen (Default: os.Getenv). Über TALK_*
// laufen die Env-Overrides sowie die Pfadauflösung (HOME,
// XDG_CONFIG_HOME, APPDATA).
// Getenv supplies environment variables (default os.Getenv): the TALK_*
// overrides and the path resolution (HOME, XDG_CONFIG_HOME, APPDATA).
Getenv func(string) string
}
// Load löst die Konfiguration schichtweise auf: Defaults → config.toml
// (falls vorhanden) → Env-Overrides (TALK_* schlägt die Datei). Env- und
// Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie still
// ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable),
// läuft talk mit Defaults + Env weiter — die Datei ist optional, kaputte
// Umgebungen dürfen talk nicht unbrauchbar machen.
// Load resolves the configuration in layers: defaults → config.toml (if
// present) → env overrides (TALK_* beats the file). Env and file validation
// errors are reported clearly, never ignored. If the config directory
// cannot be determined (no HOME), talk runs with defaults + env: the file is
// optional, a broken environment must not make talk unusable.
func Load(opts Options) (Config, error) {
if opts.Getenv == nil {
opts.Getenv = os.Getenv
@@ -201,7 +197,6 @@ func Load(opts Options) (Config, error) {
if path == "" {
dir, err := Dir(opts.Getenv)
if err != nil {
// Kein auffindbares Config-Verzeichnis: optionale Datei entfällt.
return applyEnv(cfg, opts.Getenv)
}
path = filepath.Join(dir, fileName)
@@ -215,7 +210,7 @@ func Load(opts Options) (Config, error) {
return applyEnv(cfg, opts.Getenv)
}
// applyEnv wendet die Env-Overrides an und validiert das Ergebnis.
// applyEnv applies the env overrides and validates the result.
func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_AUDIO_SINK"); v != "" {
cfg.Audio.Sink = v
@@ -223,10 +218,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_PROVIDER"); v != "" {
cfg.Provider = v
}
if v := getenv("TALK_LANG"); v != "" {
cfg.Lang = v
}
if v := getenv("TALK_PORT"); v != "" {
port, err := strconv.Atoi(v)
if err != nil || port < 1 || port > 65535 {
return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v)
return cfg, fmt.Errorf("TALK_PORT: invalid value %q (1–65535)", v)
}
cfg.Server.Port = port
}
@@ -244,21 +242,21 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_MAX_TEXT"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return cfg, fmt.Errorf("TALK_MAX_TEXT: ungültiger Wert %q (Zeichen; 0 = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_MAX_TEXT: invalid value %q (characters; 0 = unlimited)", v)
}
cfg.Queue.MaxText = n
}
if v := getenv("TALK_MAX_AGE"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_MAX_AGE: ungültiger Wert %q (Dauer, z. B. \"2m\"; \"0s\" = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_MAX_AGE: invalid value %q (duration such as \"2m\"; \"0s\" = unlimited)", v)
}
cfg.Queue.MaxAge = d
}
if v := getenv("TALK_AUTOSTART"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v)
return cfg, fmt.Errorf("TALK_AUTOSTART: invalid value %q (true/false)", v)
}
cfg.Daemon.Autostart = b
}
@@ -268,14 +266,14 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
if v := getenv("TALK_RATE_LIMIT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_RATE_LIMIT: ungültiger Wert %q (Dauer, z. B. \"30s\"; \"0s\" = unbegrenzt)", v)
return cfg, fmt.Errorf("TALK_RATE_LIMIT: invalid value %q (duration such as \"30s\"; \"0s\" = unlimited)", v)
}
cfg.Queue.RateLimit = d
}
if v := getenv("TALK_ALLOW_HIGH_BURST"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: ungültiger Wert %q (true/false)", v)
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: invalid value %q (true/false)", v)
}
cfg.Queue.AllowHighBurst = b
}
@@ -284,37 +282,39 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
cfg.QuietHours = ""
} else {
if _, err := queue.ParseQuietHours(v); err != nil {
return cfg, fmt.Errorf("TALK_QUIET_HOURS: ungültiges Fenster %q (HH:MM-HH:MM, \"off\" = deaktiviert)", v)
return cfg, fmt.Errorf("TALK_QUIET_HOURS: invalid window %q (HH:MM-HH:MM, \"off\" = disabled)", v)
}
cfg.QuietHours = v
}
}
if v := getenv("TALK_QUIET_MODE"); v != "" {
if v != queue.QuietModeDiscard && v != queue.QuietModeToast {
return cfg, fmt.Errorf("TALK_QUIET_MODE: ungültiger Wert %q (erlaubt: %q, %q)",
return cfg, fmt.Errorf("TALK_QUIET_MODE: invalid value %q (allowed: %q, %q)",
v, queue.QuietModeDiscard, queue.QuietModeToast)
}
cfg.QuietMode = v
}
if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 {
return cfg, fmt.Errorf("queue: max_text, max_age und rate_limit dürfen nicht negativ sein (0 = unbegrenzt)")
return cfg, fmt.Errorf("queue: max_text, max_age and rate_limit must not be negative (0 = unlimited)")
}
switch cfg.Audio.Sink {
case SinkOto, SinkNull:
default:
return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
return cfg, fmt.Errorf("audio.sink: invalid value %q (allowed: %q, %q)",
cfg.Audio.Sink, SinkOto, SinkNull)
}
// allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine
// leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber
// substanziell sein — klarer Fehler statt stiller Ignoranz.
if cfg.Lang != "de" && cfg.Lang != "en" {
return cfg, fmt.Errorf("lang: invalid value %q (allowed: de, en)", cfg.Lang)
}
// Validate allowed_origins from the file (env was parsed above): an empty
// list is fine (door shut), but entries must have substance.
for _, o := range cfg.Server.AllowedOrigins {
if strings.TrimSpace(o) == "" {
return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins)
return cfg, fmt.Errorf("server.allowed_origins: empty entry in %v", cfg.Server.AllowedOrigins)
}
}
// quiet_hours aus der Datei validieren (Env wurde oben geprüft) und
// quiet_mode leer (Datei ohne Wert) auf den Default heben.
// Validate quiet_hours from the file (env was checked above) and lift an
// empty quiet_mode to the default.
if cfg.QuietHours != "" {
if _, err := queue.ParseQuietHours(cfg.QuietHours); err != nil {
return cfg, err
@@ -326,16 +326,16 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
return cfg, nil
}
// splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von
// server.allowed_origins, z. B. "https://a.example, https://b.example").
// Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler.
// splitOrigins splits a comma-separated origin list (the env form of
// server.allowed_origins, for example "https://a.example, https://b.example").
// Whitespace around entries is tolerated; empty entries are an error.
func splitOrigins(v string) ([]string, error) {
parts := strings.Split(v, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v)
return nil, fmt.Errorf("empty entry in %q (comma-separated origins, or \"off\")", v)
}
origins = append(origins, p)
}
+191 -348
View File
@@ -14,7 +14,23 @@ func env(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
func TestDirForPlattformpfade(t *testing.T) {
// writeTOML writes content to a temp config.toml and returns its path.
func writeTOML(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// missingFile returns a path that does not exist (defaults + env only).
func missingFile(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "does-not-exist.toml")
}
func TestDirForPlatformPaths(t *testing.T) {
cases := []struct {
name string
goos string
@@ -22,66 +38,26 @@ func TestDirForPlattformpfade(t *testing.T) {
want string
errMsg string
}{
{
name: "linux mit HOME",
goos: "linux",
env: map[string]string{"HOME": "/home/u"},
want: "/home/u/.config/talk",
},
{
name: "linux XDG hat Vorrang",
goos: "linux",
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"},
want: "/xdg/talk",
},
{
name: "linux relativer XDG wird ignoriert",
goos: "linux",
env: map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relativ"},
want: "/home/u/.config/talk",
},
{
name: "linux ohne HOME ist Fehler",
goos: "linux",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{
name: "macos",
goos: "darwin",
env: map[string]string{"HOME": "/Users/u"},
want: "/Users/u/Library/Application Support/talk",
},
{
name: "macos ohne HOME ist Fehler",
goos: "darwin",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{
name: "windows",
goos: "windows",
env: map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`},
want: filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"),
},
{
name: "windows ohne APPDATA ist Fehler",
goos: "windows",
env: map[string]string{},
errMsg: ErrNoHome.Error(),
},
{"linux with HOME", "linux", map[string]string{"HOME": "/home/u"}, "/home/u/.config/talk", ""},
{"linux XDG wins", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "/xdg"}, "/xdg/talk", ""},
{"linux relative XDG is ignored", "linux", map[string]string{"HOME": "/home/u", "XDG_CONFIG_HOME": "relative"}, "/home/u/.config/talk", ""},
{"linux without HOME is an error", "linux", map[string]string{}, "", ErrNoHome.Error()},
{"macos", "darwin", map[string]string{"HOME": "/Users/u"}, "/Users/u/Library/Application Support/talk", ""},
{"macos without HOME is an error", "darwin", map[string]string{}, "", ErrNoHome.Error()},
{"windows", "windows", map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`}, filepath.Join(`C:\Users\u\AppData\Roaming`, "talk"), ""},
{"windows without APPDATA is an error", "windows", map[string]string{}, "", ErrNoHome.Error()},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := dirFor(tc.goos, env(tc.env))
if tc.errMsg != "" {
if err == nil || err.Error() != tc.errMsg {
t.Fatalf("dirFor(%q) Fehler = %v, want %q", tc.goos, err, tc.errMsg)
t.Fatalf("dirFor(%q) error = %v, want %q", tc.goos, err, tc.errMsg)
}
return
}
if err != nil {
t.Fatalf("dirFor(%q): unerwarteter Fehler %v", tc.goos, err)
t.Fatalf("dirFor(%q): unexpected error %v", tc.goos, err)
}
if got != tc.want {
t.Errorf("dirFor(%q) = %q, want %q", tc.goos, got, tc.want)
@@ -90,58 +66,39 @@ func TestDirForPlattformpfade(t *testing.T) {
}
}
// TestLoadMerge ist tischgesteuert für die Schichtenfolge
// Defaults → TOML-Datei → Env-Override.
// TestLoadMerge pins the layering defaults → TOML file → env override.
func TestLoadMerge(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("nur Defaults", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("defaults only", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"null\"\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[audio]\nsink = \"null\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want %q aus TOML", cfg.Audio.Sink, SinkNull)
t.Errorf("Audio.Sink = %q, want %q from TOML", cfg.Audio.Sink, SinkNull)
}
})
t.Run("leere TOML-Sektion lässt Default stehen", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "# nur ein Kommentar\n[audio]\n"),
Getenv: env(nil),
})
t.Run("empty TOML section keeps default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "# just a comment\n[audio]\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
t.Run("Env schlägt Datei", func(t *testing.T) {
t.Run("env beats file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"oto\"\n"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
@@ -150,15 +107,12 @@ func TestLoadMerge(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Audio.Sink != SinkNull {
t.Errorf("Audio.Sink = %q, want Env-Override %q", cfg.Audio.Sink, SinkNull)
t.Errorf("Audio.Sink = %q, want env override %q", cfg.Audio.Sink, SinkNull)
}
})
t.Run("Env allein ohne Datei", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"}),
})
t.Run("env alone without file", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "null"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -167,59 +121,40 @@ func TestLoadMerge(t *testing.T) {
}
})
t.Run("ungültiger Sink-Wert in Datei ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"),
Getenv: env(nil),
})
t.Run("invalid sink in file is a clear error", func(t *testing.T) {
_, err := Load(Options{File: writeTOML(t, "[audio]\nsink = \"mpv\"\n"), Getenv: env(nil)})
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
t.Fatalf("Load error = %v, want a clear audio.sink error", err)
}
})
t.Run("ungültiger Env-Wert ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"}),
})
if err == nil || !strings.Contains(err.Error(), "TALK") && !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load-Fehler = %v, will klaren audio.sink-Fehler", err)
t.Run("invalid env value is a clear error", func(t *testing.T) {
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUDIO_SINK": "bogus"})})
if err == nil || !strings.Contains(err.Error(), "audio.sink") {
t.Fatalf("Load error = %v, want a clear audio.sink error", err)
}
})
t.Run("kaputte TOML-Syntax ist klarer Fehler", func(t *testing.T) {
_, err := Load(Options{
File: writeTOML(t, "[audio\nsink = \n"),
Getenv: env(nil),
})
if err == nil {
t.Fatal("Load bei kaputter TOML: kein Fehler")
t.Run("broken TOML syntax is a clear error", func(t *testing.T) {
if _, err := Load(Options{File: writeTOML(t, "[audio\nsink = \n"), Getenv: env(nil)}); err == nil {
t.Fatal("Load with broken TOML: no error")
}
})
t.Run("ohne Home läuft Load mit Defaults weiter", func(t *testing.T) {
t.Run("without HOME Load continues with defaults", func(t *testing.T) {
cfg, err := Load(Options{Getenv: env(nil)})
if err != nil {
t.Fatalf("Load ohne HOME: %v", err)
t.Fatalf("Load without HOME: %v", err)
}
if cfg.Audio.Sink != SinkOto {
t.Errorf("Audio.Sink = %q, want Default %q", cfg.Audio.Sink, SinkOto)
t.Errorf("Audio.Sink = %q, want default %q", cfg.Audio.Sink, SinkOto)
}
})
}
// TestLoadProviderKette deckt die Task-03-Felder ab: Provider-Pin per
// Datei/Env, Provider-Endpunkte/-Modelle und das Voice-Mapping.
func TestLoadProviderKette(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// TestLoadProviderChain covers the provider pin (file/env), per-provider
// endpoints/models and the voice mapping.
func TestLoadProviderChain(t *testing.T) {
toml := `provider = "openai"
[providers.elevenlabs]
@@ -233,14 +168,14 @@ model = "gpt-4o-mini-tts"
endpoint = "wss://edge.example.com/synth"
[voice.elevenlabs]
de = "MeineStimme"
de = "MyGermanVoice"
en = "MyVoice"
[voice.edge]
de = "de-DE-KatjaNeural"
`
t.Run("TOML liefert Pin, Endpunkte und Voice-Mapping", func(t *testing.T) {
t.Run("TOML yields pin, endpoints and voice mapping", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
@@ -257,7 +192,7 @@ de = "de-DE-KatjaNeural"
if got := cfg.Providers["edge"].Endpoint; got != "wss://edge.example.com/synth" {
t.Errorf("Providers[edge].Endpoint = %q", got)
}
if got := cfg.Voice["elevenlabs"]["de"]; got != "MeineStimme" {
if got := cfg.Voice["elevenlabs"]["de"]; got != "MyGermanVoice" {
t.Errorf("Voice[elevenlabs][de] = %q", got)
}
if got := cfg.Voice["elevenlabs"]["en"]; got != "MyVoice" {
@@ -265,74 +200,51 @@ de = "de-DE-KatjaNeural"
}
})
t.Run("TALK_PROVIDER schlägt Datei-Pin", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, toml),
Getenv: env(map[string]string{"TALK_PROVIDER": "edge"}),
})
t.Run("TALK_PROVIDER beats the file pin", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, toml), Getenv: env(map[string]string{"TALK_PROVIDER": "edge"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Provider != "edge" {
t.Errorf("Provider = %q, want Env-Override %q", cfg.Provider, "edge")
t.Errorf("Provider = %q, want env override %q", cfg.Provider, "edge")
}
})
t.Run("ohne alles: keine Pins, keine Endpunkte", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("nothing configured: no pin, no endpoints", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Provider != "" || cfg.Providers != nil || cfg.Voice != nil {
t.Errorf("Provider-Konfiguration = %+v, will leer", cfg)
t.Errorf("provider configuration = %+v, want empty", cfg)
}
})
}
// TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults
// (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung.
// TestLoadWrapLabel: das Ansage-Label von `talk wrap` (Task 07) —
// Defaults → TOML → Env nach dem üblichen Schichten-Muster.
// TestLoadWrapLabel: the `talk wrap` announcement label, defaults → TOML →
// env in the usual layering.
func TestLoadWrapLabel(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Default leer (Basisname des Befehls)", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default empty (command base name)", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "" {
t.Errorf("Wrap.Label = %q, want leer (Default)", cfg.Wrap.Label)
t.Errorf("Wrap.Label = %q, want empty (default)", cfg.Wrap.Label)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "Deployment" {
t.Errorf("Wrap.Label = %q, want %q aus TOML", cfg.Wrap.Label, "Deployment")
t.Errorf("Wrap.Label = %q, want %q from TOML", cfg.Wrap.Label, "Deployment")
}
})
t.Run("TALK_WRAP_LABEL schlägt Datei", func(t *testing.T) {
t.Run("TALK_WRAP_LABEL beats the file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[wrap]\nlabel = \"Deployment\"\n"),
Getenv: env(map[string]string{"TALK_WRAP_LABEL": "Nightly Build"}),
@@ -341,48 +253,35 @@ func TestLoadWrapLabel(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Wrap.Label != "Nightly Build" {
t.Errorf("Wrap.Label = %q, want Env-Override %q", cfg.Wrap.Label, "Nightly Build")
t.Errorf("Wrap.Label = %q, want env override %q", cfg.Wrap.Label, "Nightly Build")
}
})
}
// TestLoadServerPort: daemon port by default (7800), TOML [server] port and
// env override TALK_PORT including validation.
func TestLoadServerPort(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Default 7800", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default 7800", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != DefaultServerPort {
t.Errorf("Server.Port = %d, want Default %d", cfg.Server.Port, DefaultServerPort)
t.Errorf("Server.Port = %d, want default %d", cfg.Server.Port, DefaultServerPort)
}
})
t.Run("TOML überschreibt Default", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nport = 7999\n"),
Getenv: env(nil),
})
t.Run("TOML overrides default", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[server]\nport = 7999\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 7999 {
t.Errorf("Server.Port = %d, want 7999 aus TOML", cfg.Server.Port)
t.Errorf("Server.Port = %d, want 7999 from TOML", cfg.Server.Port)
}
})
t.Run("TALK_PORT schlägt Datei", func(t *testing.T) {
t.Run("TALK_PORT beats the file", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nport = 7999\n"),
Getenv: env(map[string]string{"TALK_PORT": "7912"}),
@@ -391,92 +290,73 @@ func TestLoadServerPort(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 7912 {
t.Errorf("Server.Port = %d, want Env-Override 7912", cfg.Server.Port)
t.Errorf("Server.Port = %d, want env override 7912", cfg.Server.Port)
}
})
t.Run("ungültiger TALK_PORT-Wert ist klarer Fehler", func(t *testing.T) {
t.Run("invalid TALK_PORT is a clear error", func(t *testing.T) {
for _, bad := range []string{"null", "0", "-1", "70000"} {
_, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_PORT": bad}),
})
_, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_PORT": bad})})
if err == nil || !strings.Contains(err.Error(), "TALK_PORT") {
t.Fatalf("TALK_PORT=%q: Fehler = %v, will klaren TALK_PORT-Fehler", bad, err)
t.Fatalf("TALK_PORT=%q: error = %v, want a clear TALK_PORT error", bad, err)
}
}
})
}
func TestLoadAllowedOrigins(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(nil),
})
t.Run("default: empty list (no CORS)", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Server.AllowedOrigins) != 0 {
t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins)
t.Errorf("AllowedOrigins = %v, want empty (door shut)", cfg.Server.AllowedOrigins)
}
})
t.Run("TOML-Liste", func(t *testing.T) {
t.Run("TOML list", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.example.com\"]\n"),
Getenv: env(nil),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"https://chat.az-gruppe.com"}
if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
if strings.Join(cfg.Server.AllowedOrigins, "|") != "https://chat.example.com" {
t.Errorf("AllowedOrigins = %v", cfg.Server.AllowedOrigins)
}
})
t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) {
t.Run("TALK_ALLOWED_ORIGINS beats the file (several, with spaces)", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.example.com, https://grid.example"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"https://chat.az-gruppe.com", "https://grid.ag"}
if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") {
want := "https://chat.example.com|https://grid.example"
if strings.Join(cfg.Server.AllowedOrigins, "|") != want {
t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want)
}
})
t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) {
t.Run("TALK_ALLOWED_ORIGINS=off clears the file list", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"),
File: writeTOML(t, "[server]\nallowed_origins = [\"https://file.example\"]\n"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Server.AllowedOrigins) != 0 {
t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins)
t.Errorf("AllowedOrigins = %v, want empty (off)", cfg.Server.AllowedOrigins)
}
})
t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) {
cfg, err := Load(Options{
File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}),
})
t.Run("wildcard is an allowed value", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
@@ -485,69 +365,53 @@ func TestLoadAllowedOrigins(t *testing.T) {
}
})
t.Run("leere Einträge sind klare Fehler", func(t *testing.T) {
t.Run("empty entries are clear errors", func(t *testing.T) {
for _, tc := range []struct {
name string
file string
envv string
}{
{"env", "", "https://a.example,,https://b.example"},
{"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
{"file", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""},
} {
t.Run(tc.name, func(t *testing.T) {
file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
file := missingFile(t)
if tc.file != "" {
file = writeTOML(t, tc.file)
}
_, err := Load(Options{
File: file,
Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}),
})
_, err := Load(Options{File: file, Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv})})
if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") {
t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err)
t.Fatalf("error = %v, want a clear allowed_origins error", err)
}
})
}
})
}
// TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
// "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
// Format validiert) samt Env-Overrides und klaren Fehlern.
func TestLoadGesetze(t *testing.T) {
writeTOML := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
fehlendeDatei := func(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
}
t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) {
cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)})
// TestLoadLaws covers rate limit (default 30 s, "0s" = unlimited), high
// burst (default on) and quiet hours (default off; format validated) with
// env overrides and clear errors.
func TestLoadLaws(t *testing.T) {
t.Run("defaults: 30 s, burst on, quiet hours off, mode discard", func(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != DefaultRateLimit {
t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit)
t.Errorf("Queue.RateLimit = %v, want default %v", cfg.Queue.RateLimit, DefaultRateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want Default true")
t.Error("Queue.AllowHighBurst = false, want default true")
}
if cfg.QuietHours != "" {
t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours)
t.Errorf("QuietHours = %q, want default empty (off)", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeDiscard {
t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard)
t.Errorf("QuietMode = %q, want default %q", cfg.QuietMode, queue.QuietModeDiscard)
}
})
t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) {
t.Run("TOML sets all law fields", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, `
quiet_hours = "18:00-08:00"
@@ -566,36 +430,33 @@ allow_high_burst = false
t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit)
}
if cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = true, want false aus TOML")
t.Error("Queue.AllowHighBurst = true, want false from TOML")
}
if cfg.QuietHours != "18:00-08:00" {
t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours)
t.Errorf("QuietHours = %q, want window from TOML", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeToast {
t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
}
})
t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"),
Getenv: env(nil),
})
t.Run("partial [queue] table keeps defaults", func(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != 0 {
t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit)
t.Errorf("Queue.RateLimit = %v, want 0 (unlimited)", cfg.Queue.RateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want unveränderten Default true")
t.Error("Queue.AllowHighBurst = false, want unchanged default true")
}
if cfg.QuietMode != queue.QuietModeDiscard {
t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode)
t.Errorf("QuietMode = %q, want unchanged default discard", cfg.QuietMode)
}
})
t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) {
t.Run("env overrides file (including off)", func(t *testing.T) {
cfg, err := Load(Options{
File: writeTOML(t, `
quiet_hours = "18:00-08:00"
@@ -616,96 +477,58 @@ allow_high_burst = false
t.Fatalf("Load: %v", err)
}
if cfg.Queue.RateLimit != time.Second {
t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit)
t.Errorf("Queue.RateLimit = %v, want 1s from env", cfg.Queue.RateLimit)
}
if !cfg.Queue.AllowHighBurst {
t.Error("Queue.AllowHighBurst = false, want true aus Env")
t.Error("Queue.AllowHighBurst = false, want true from env")
}
if cfg.QuietHours != "" {
t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours)
t.Errorf("QuietHours = %q, want empty (off from env)", cfg.QuietHours)
}
if cfg.QuietMode != queue.QuietModeToast {
t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode)
t.Errorf("QuietMode = %q, want toast from env", cfg.QuietMode)
}
})
t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) {
t.Run("invalid law values are clear errors", func(t *testing.T) {
cases := []struct {
name string
toml string
envs map[string]string
want string
}{
{
name: "TALK_RATE_LIMIT Müll",
envs: map[string]string{"TALK_RATE_LIMIT": "schnell"},
want: "TALK_RATE_LIMIT",
},
{
name: "TALK_RATE_LIMIT negativ",
envs: map[string]string{"TALK_RATE_LIMIT": "-5s"},
want: "TALK_RATE_LIMIT",
},
{
name: "rate_limit Müll in Datei",
toml: "[queue]\nrate_limit = \"müll\"\n",
want: "rate_limit",
},
{
name: "TALK_ALLOW_HIGH_BURST Müll",
envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"},
want: "TALK_ALLOW_HIGH_BURST",
},
{
name: "quiet_hours Müll in Datei",
toml: "quiet_hours = \"abends\"\n",
want: "quiet_hours",
},
{
name: "TALK_QUIET_HOURS Müll",
envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"},
want: "TALK_QUIET_HOURS",
},
{
name: "TALK_QUIET_MODE Müll",
envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"},
want: "TALK_QUIET_MODE",
},
{"TALK_RATE_LIMIT garbage", "", map[string]string{"TALK_RATE_LIMIT": "fast"}, "TALK_RATE_LIMIT"},
{"TALK_RATE_LIMIT negative", "", map[string]string{"TALK_RATE_LIMIT": "-5s"}, "TALK_RATE_LIMIT"},
{"rate_limit garbage in file", "[queue]\nrate_limit = \"garbage\"\n", nil, "rate_limit"},
{"TALK_ALLOW_HIGH_BURST garbage", "", map[string]string{"TALK_ALLOW_HIGH_BURST": "maybe"}, "TALK_ALLOW_HIGH_BURST"},
{"quiet_hours garbage in file", "quiet_hours = \"evenings\"\n", nil, "quiet_hours"},
{"TALK_QUIET_HOURS garbage", "", map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"}, "TALK_QUIET_HOURS"},
{"TALK_QUIET_MODE garbage", "", map[string]string{"TALK_QUIET_MODE": "vibrate"}, "TALK_QUIET_MODE"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
file := fehlendeDatei(t)
file := missingFile(t)
if tc.toml != "" {
file = writeTOML(t, tc.toml)
}
_, err := Load(Options{File: file, Getenv: env(tc.envs)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want)
t.Fatalf("error = %v, want a clear error mentioning %q", err, tc.want)
}
})
}
})
}
// TestLoadDaemonAutostart deckt [daemon] autostart ab: Default an, TOML
// kann es ausschalten, Env TALK_AUTOSTART schlägt die Datei, Unsinn ist ein
// klarer Fehler.
// TestLoadDaemonAutostart covers [daemon] autostart: default on, TOML can
// switch it off, env TALK_AUTOSTART beats the file, garbage is an error.
func TestLoadDaemonAutostart(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want Default true")
t.Error("Daemon.Autostart = false, want default true")
}
cfg, err = Load(Options{File: writeTOML(t, "[daemon]\nautostart = false\n"), Getenv: env(nil)})
@@ -713,7 +536,7 @@ func TestLoadDaemonAutostart(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = true, want false aus TOML")
t.Error("Daemon.Autostart = true, want false from TOML")
}
cfg, err = Load(Options{
@@ -724,33 +547,24 @@ func TestLoadDaemonAutostart(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if !cfg.Daemon.Autostart {
t.Error("Daemon.Autostart = false, want true aus Env (Env schlägt Datei)")
t.Error("Daemon.Autostart = false, want true from env (env beats file)")
}
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(map[string]string{"TALK_AUTOSTART": "vielleicht"})}); err == nil {
t.Error("TALK_AUTOSTART=vielleicht: kein Fehler, want klarer Fehler")
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_AUTOSTART": "maybe"})}); err == nil {
t.Error("TALK_AUTOSTART=maybe: no error, want a clear error")
}
}
// TestLoadLimits deckt queue.max_text / queue.max_age ab: Defaults 400 / 2m,
// TOML und Env (Env schlägt Datei), 0 = unbegrenzt, Unsinn und negative
// Werte sind klare Fehler.
// TestLoadLimits covers queue.max_text / queue.max_age: defaults 400 / 2m,
// TOML and env (env beats file), 0 = unlimited, garbage and negative values
// are clear errors.
func TestLoadLimits(t *testing.T) {
fehlendeDatei := filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
writeTOML := func(t *testing.T, content string) string {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
cfg, err := Load(Options{File: fehlendeDatei, Getenv: env(nil)})
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.MaxText != DefaultMaxText || cfg.Queue.MaxAge != DefaultMaxAge {
t.Errorf("Defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
t.Errorf("defaults = %d/%v, want %d/%v", cfg.Queue.MaxText, cfg.Queue.MaxAge, DefaultMaxText, DefaultMaxAge)
}
cfg, err = Load(Options{File: writeTOML(t, "[queue]\nmax_text = 0\nmax_age = \"45s\"\n"), Getenv: env(nil)})
@@ -769,20 +583,49 @@ func TestLoadLimits(t *testing.T) {
t.Fatalf("Load: %v", err)
}
if cfg.Queue.MaxText != 250 || cfg.Queue.MaxAge != 0 {
t.Errorf("Env = %d/%v, want 250/0 (Env schlägt Datei)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
t.Errorf("env = %d/%v, want 250/0 (env beats file)", cfg.Queue.MaxText, cfg.Queue.MaxAge)
}
for name, kv := range map[string]map[string]string{
"TALK_MAX_TEXT=abc": {"TALK_MAX_TEXT": "abc"},
"TALK_MAX_TEXT=-1": {"TALK_MAX_TEXT": "-1"},
"TALK_MAX_AGE=bald": {"TALK_MAX_AGE": "bald"},
"TALK_MAX_AGE=soon": {"TALK_MAX_AGE": "soon"},
"TALK_MAX_AGE=-1s": {"TALK_MAX_AGE": "-1s"},
} {
if _, err := Load(Options{File: fehlendeDatei, Getenv: env(kv)}); err == nil {
t.Errorf("%s: kein Fehler, want klarer Fehler", name)
if _, err := Load(Options{File: missingFile(t), Getenv: env(kv)}); err == nil {
t.Errorf("%s: no error, want a clear error", name)
}
}
if _, err := Load(Options{File: writeTOML(t, "[queue]\nmax_text = -5\n"), Getenv: env(nil)}); err == nil {
t.Error("max_text = -5 in TOML: kein Fehler, want klarer Fehler")
t.Error("max_text = -5 in TOML: no error, want a clear error")
}
}
// TestLoadLang covers the fallback language: default en, TOML, env
// TALK_LANG beats the file, anything but de/en is an error.
func TestLoadLang(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want default en", cfg.Lang)
}
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "de" {
t.Errorf("Lang = %q, want de from TOML", cfg.Lang)
}
cfg, err = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(map[string]string{"TALK_LANG": "en"})})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q, want en from env", cfg.Lang)
}
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_LANG": "fr"})}); err == nil || !strings.Contains(err.Error(), "lang") {
t.Errorf("TALK_LANG=fr: error = %v, want a clear lang error", err)
}
}
+4 -6
View File
@@ -1,7 +1,5 @@
// Package config implementiert die Config-Auflösung von talk: Defaults +
// optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
// ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
// (TALK_*); Env schlägt Datei. Pfade und Env sind injizierbar (Tests).
// Voice-Mapping voice.<provider>.<lang> und Provider-Endpunkte folgen mit
// Task 03.
// Package config resolves talk's configuration in layers: built-in defaults,
// then the optional config.toml in the platform-native config directory,
// then TALK_* environment variables (which win). It validates the result and
// reports problems as clear errors instead of silently falling back.
package config
+14 -15
View File
@@ -6,26 +6,25 @@ import (
"runtime"
)
// fileName ist der Name der Config-Datei im Config-Verzeichnis.
// fileName is the config file's name inside the config directory.
const fileName = "config.toml"
// ErrNoHome meldet, dass das Heimatverzeichnis nicht bestimmt werden kann
// (keine HOME- bzw. APPDATA-Umgebungsvariable).
var ErrNoHome = errors.New("Config-Verzeichnis nicht bestimmbar (HOME bzw. APPDATA fehlt)")
// ErrNoHome reports that the home directory cannot be determined (no HOME or
// APPDATA environment variable).
var ErrNoHome = errors.New("config directory unknown (HOME or APPDATA not set)")
// Dir liefert das platform-native Config-Verzeichnis von talk auf dem
// aktuellen System: Linux ~/.config/talk (respektive $XDG_CONFIG_HOME/talk),
// macOS ~/Library/Application Support/talk, Windows %APPDATA%\talk.
// Dir returns talk's platform-native config directory: ~/.config/talk on
// Linux (or $XDG_CONFIG_HOME/talk), ~/Library/Application Support/talk on
// macOS, %APPDATA%\talk on Windows.
func Dir(getenv func(string) string) (string, error) {
return dirFor(runtime.GOOS, getenv)
}
// dirFor ist die testbare Plattformlogik hinter Dir: goos wird als
// Parameter übergeben, damit die Pfade für alle Betriebssysteme vom
// Dev-Host aus tischgesteuert geprüft werden können.
// dirFor is the testable platform logic behind Dir: goos is a parameter so
// all platforms can be table-tested from one host.
//
// XDG_CONFIG_HOME wird nur berücksichtigt, wenn es einen absoluten Pfad
// enthält (XDG-Spezifikation); relative Werte werden ignoriert.
// XDG_CONFIG_HOME is honoured only when it holds an absolute path (XDG
// spec); relative values are ignored.
func dirFor(goos string, getenv func(string) string) (string, error) {
if getenv == nil {
return "", ErrNoHome
@@ -43,7 +42,7 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
return "", ErrNoHome
}
return filepath.Join(home, "Library", "Application Support", "talk"), nil
default: // linux und sonstige unixartige Systeme
default: // linux and other unix-likes
if xdg := getenv("XDG_CONFIG_HOME"); xdg != "" && filepath.IsAbs(xdg) {
return filepath.Join(xdg, "talk"), nil
}
@@ -55,8 +54,8 @@ func dirFor(goos string, getenv func(string) string) (string, error) {
}
}
// Path liefert den vollständigen Pfad der config.toml im platform-nativen
// Config-Verzeichnis (Diagnose, Setup-Wizard).
// Path returns the full path of config.toml in the config directory
// (diagnostics, setup).
func Path(getenv func(string) string) (string, error) {
dir, err := Dir(getenv)
if err != nil {
+51 -49
View File
@@ -1,9 +1,9 @@
// Package daemon ist der Hintergrundprozess von talk: HTTP-API auf
// 127.0.0.1, serialisierte Ansage-Queue mit Prioritäten und now-Sprung,
// persistenter Mute-State, die Anti-Spam-Gesetze (Rate-Limit, Ruhezeiten),
// CORS-Tür und MCP-Endpoint. Es gibt genau ein Binary: `talk daemon run`
// betreibt den Daemon im Vordergrund, die CLI startet ihn mit Spawn bei
// Bedarf selbst im Hintergrund (daemon.autostart).
// Package daemon is talk's background process: HTTP API on 127.0.0.1, a
// serialized announcement queue with priorities and the now jump,
// persistent mute state, the anti-spam laws (rate limit, quiet hours), the
// CORS door and the MCP endpoint. There is exactly one binary: `talk daemon
// run` runs the daemon in the foreground, and the CLI starts it in the
// background itself via Spawn when needed (daemon.autostart).
package daemon
import (
@@ -28,37 +28,38 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// Dateien des Daemons im Config-Verzeichnis.
// Files of the daemon in the config directory.
const (
// LogFileName ist das Protokoll eines im Hintergrund gestarteten
// Daemons (Spawn leitet stdout/stderr dorthin; pro Start neu).
// LogFileName is the log of a daemon started in the background (Spawn
// redirects stdout/stderr there; fresh per start).
LogFileName = "daemon.log"
// PIDFileName hält die Prozess-ID des laufenden Daemons (für
// Diagnose und als Notbremse, wenn die HTTP-API nicht antwortet).
// PIDFileName holds the process ID of the running daemon (for
// diagnostics and as an emergency brake when the HTTP API does not
// answer).
PIDFileName = "daemon.pid"
)
// Options sind die Abhängigkeiten von Run.
// Options are the dependencies of Run.
type Options struct {
// Config ist die geladene Konfiguration (Port, Gesetze, Sink, …).
// Config is the loaded configuration (port, laws, sink, ...).
Config config.Config
// Getenv liefert Keys und Endpunkt-Overrides (nil = os.Getenv).
// Getenv supplies keys and endpoint overrides (nil = os.Getenv).
Getenv func(string) string
// Version ist die Daemon-Version für /status und MCP serverInfo.
// Version is the daemon version for /status and MCP serverInfo.
Version string
// Log empfängt das Protokoll (Vordergrund: stderr; Hintergrund: die
// Log-Datei). nil = os.Stderr.
// Log receives the log output (foreground: stderr; background: the log
// file). nil = os.Stderr.
Log io.Writer
}
// Run betreibt den Daemon, bis ctx endet (Signal), POST /shutdown kommt
// oder der HTTP-Server scheitert. Eine laufende Ansage wird beim
// Herunterfahren abgebrochen — der Daemon soll prompt reagieren. Ein
// belegter Port ist ein klarer Fehler („läuft bereits ein Daemon?").
// Run runs the daemon until ctx ends (signal), POST /shutdown arrives or
// the HTTP server fails. A playing announcement is cancelled on shutdown;
// the daemon should react promptly. A port in use is a clear error ("is
// another daemon running?").
func Run(ctx context.Context, opts Options) error {
getenv := opts.Getenv
if getenv == nil {
@@ -77,8 +78,8 @@ func Run(ctx context.Context, opts Options) error {
return err
}
// Anti-Spam-Gesetz: config.Load hat quiet_hours validiert — ein Fehler
// hier ist praktisch unmöglich, darf aber nie still werden.
// Anti-spam law: config.Load has validated quiet_hours; an error here
// is practically impossible but must never be silent.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
if err != nil {
return err
@@ -98,15 +99,15 @@ func Run(ctx context.Context, opts Options) error {
Voice: item.Voice,
})
if err == nil && !out.Discarded {
logger.Printf("ansage %s über %s (synthese %s, wiedergabe %s)", item.ID, out.Provider,
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
}
return err
}, lw)
// Ruhezeit-Toast: Desktop-Benachrichtigung statt Audio (quiet_mode =
// "toast"); best effort, Fehler nur im Protokoll. Läuft asynchron, damit
// ein hängender Notification-Daemon nie die Queue blockiert.
// Quiet-hours toast: desktop notification instead of audio (quiet_mode
// = "toast"); best effort, errors only in the log. Runs asynchronously
// so a hanging notification daemon never blocks the queue.
if cfg.QuietMode == queue.QuietModeToast {
if notify.Available() {
q.OnToast(func(item queue.Item) {
@@ -117,18 +118,19 @@ func Run(ctx context.Context, opts Options) error {
}()
})
} else {
logger.Printf("warnung: quiet_mode = toast, aber %s — Ruhezeit-Ansagen werden nur protokolliert", notify.Describe())
logger.Printf("warning: quiet_mode = toast, but %s; quiet-hours announcements are only logged", notify.Describe())
}
}
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
// Persistent mute state and PID file in the config dir. Without a
// config dir the daemon keeps running without both (warning) instead
// of failing.
var mutePath, pidPath string
if dir, err := config.Dir(getenv); err == nil {
mutePath = filepath.Join(dir, queue.MuteFileName)
pidPath = filepath.Join(dir, PIDFileName)
} else {
logger.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State und PID-Datei deaktiviert (%v)", err)
logger.Printf("warning: config directory unknown; mute state and PID file disabled (%v)", err)
}
mute := queue.MuteStore{Path: mutePath}
@@ -156,12 +158,12 @@ func Run(ctx context.Context, opts Options) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
q.Close()
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talk daemon?", addr, err)
return fmt.Errorf("bind %s: %w; is another talk daemon running?", addr, err)
}
if pidPath != "" {
writePID(pidPath, logger)
// Die PID-Datei verschwindet als Letztes — `talk daemon stop`
// wartet darauf und weiß dann: Prozess ist fertig, nicht nur HTTP.
// The PID file disappears last: `talk daemon stop` waits for that
// and then knows the process is done, not just HTTP.
defer os.Remove(pidPath)
}
@@ -169,11 +171,11 @@ func Run(ctx context.Context, opts Options) error {
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
corsLog := "zu (kein CORS)"
corsLog := "closed (no CORS)"
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
@@ -182,14 +184,14 @@ func Run(ctx context.Context, opts Options) error {
q.Close()
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Printf("Signal empfangen — fahre herunter (laufende Ansage wird abgebrochen)")
logger.Printf("signal received; shutting down (the playing announcement is cancelled)")
case <-shutdown:
logger.Printf("Shutdown angefordert (talk daemon stop) — fahre herunter")
logger.Printf("shutdown requested (talk daemon stop); shutting down")
}
// Reihenfolge: erst HTTP dicht (keine neuen Anfragen), dann die Queue
// schließen (laufende Ansage abbrechen, Worker beenden und darauf
// warten). So läuft nie eine Ansage nach dem Daemon-Ende weiter.
// Order: close HTTP first (no new requests), then close the queue
// (cancel the playing announcement, stop the worker and wait for it).
// That way no announcement ever keeps playing after the daemon ends.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
@@ -199,19 +201,19 @@ func Run(ctx context.Context, opts Options) error {
return nil
}
// writePID schreibt die eigene Prozess-ID (best effort; ein Fehler ist nur
// eine Protokollzeile — der Daemon funktioniert auch ohne PID-Datei).
// writePID writes our own process ID (best effort; an error is only a log
// line, the daemon works without a PID file too).
func writePID(path string, logger *log.Logger) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
logger.Printf("warning: PID file: %v", err)
return
}
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
logger.Printf("warning: PID file: %v", err)
}
}
// ReadPID liest die PID-Datei; 0, wenn keine (lesbare) Datei existiert.
// ReadPID reads the PID file; 0 if no (readable) file exists.
func ReadPID(dir string) int {
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
if err != nil {
@@ -224,9 +226,9 @@ func ReadPID(dir string) int {
return pid
}
// lineWriter reicht Zeilen an den Logger weiter (Zeitstempel + Präfix),
// statt sie roh zu schreiben: Provider-Kette, Queue und null-Sink schreiben
// zeilenweise, so entsteht ein einheitliches Protokoll.
// lineWriter forwards lines to the logger (timestamp + prefix) instead of
// writing them raw: provider chain, queue and null sink write line by line,
// which yields one uniform log.
type lineWriter struct{ l *log.Logger }
func (w lineWriter) Write(p []byte) (int, error) {
+12 -11
View File
@@ -6,15 +6,16 @@ import (
"os/exec"
)
// Spawn startet `exe daemon run` als eigenständigen Hintergrundprozess
// (eigene Session bzw. eigene Prozessgruppe, losgelöst vom Terminal), mit
// stdout/stderr in die Log-Datei logPath (pro Start neu angelegt; leer =
// verwerfen). env ist die Umgebung des Kindes (Keys, TALK_*-Overrides).
// Spawn starts `exe daemon run` as an independent background process (own
// session or own process group, detached from the terminal), with
// stdout/stderr going to the log file logPath (created fresh per start;
// empty = discard). env is the child's environment (keys, TALK_*
// overrides).
//
// Rückgabe ist ein Kanal, der genau einmal das Wait-Ergebnis liefert, wenn
// das Kind endet — der Aufrufer erkennt daran einen fehlgeschlagenen Start
// (z. B. Port belegt), ohne auf einen Timeout warten zu müssen. Endet der
// Aufrufer zuerst, läuft der Daemon weiter (das ist der Zweck).
// The return value is a channel that delivers the Wait result exactly once
// when the child exits; the caller uses it to detect a failed start (e.g.
// port in use) without waiting for a timeout. If the caller exits first,
// the daemon keeps running (that is the point).
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
var logFile *os.File
if logPath == "" {
@@ -22,9 +23,9 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, fmt.Errorf("Daemon-Log %s: %w", logPath, err)
return nil, fmt.Errorf("daemon log %s: %w", logPath, err)
}
defer logFile.Close() // das Kind hält sein eigenes Handle
defer logFile.Close() // the child holds its own handle
cmd := exec.Command(exe, "daemon", "run")
cmd.Env = env
@@ -33,7 +34,7 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
cmd.Stderr = logFile
detach(cmd)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("Daemon starten (%s daemon run): %w", exe, err)
return nil, fmt.Errorf("starting daemon (%s daemon run): %w", exe, err)
}
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
+1 -1
View File
@@ -4,5 +4,5 @@ package daemon
import "os/exec"
// detach ist auf dieser Plattform ein No-op (kein Setsid, keine Konsole).
// detach is a no-op on this platform (no Setsid, no console).
func detach(*exec.Cmd) {}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"syscall"
)
// detach löst das Kind vom Terminal: eigene Session (kein SIGHUP beim
// Schließen des Terminals, kein Ctrl-C der Shell).
// detach detaches the child from the terminal: own session (no SIGHUP when
// the terminal closes, no Ctrl-C from the shell).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
+4 -4
View File
@@ -7,12 +7,12 @@ import (
"syscall"
)
// detachedProcess ist das Win32-Flag DETACHED_PROCESS: kein Konsolenfenster,
// keine Bindung an die Konsole des Aufrufers.
// detachedProcess is the Win32 flag DETACHED_PROCESS: no console window, no
// binding to the caller's console.
const detachedProcess = 0x00000008
// detach löst das Kind von der Konsole: eigene Prozessgruppe (kein Ctrl-C
// der Shell) und keine Konsole (kein aufblitzendes Fenster).
// detach detaches the child from the console: own process group (no Ctrl-C
// from the shell) and no console (no flashing window).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,
+19 -14
View File
@@ -1,3 +1,7 @@
// Package langdetect guesses whether an announcement is German or English.
// It is a pure function over two small word lists, deliberately without any
// ML: announcements are one or two sentences, and the only question is which
// voice should read them.
package langdetect
import (
@@ -5,14 +9,12 @@ import (
"unicode"
)
// Detect erkennt die Sprache eines Ansage-Texts und liefert "de" oder "en".
// Default (auch bei leerem oder unentscheidbarem Text) ist "de".
//
// Heuristik: Common-Word-Listen deutscher und englischer Wörter plus
// Umlaute/ß als starke Deutsch-Signale. Englisch gewinnt nur bei strikt
// mehr englischen Signalen als deutschen; bei Gleichstand entscheidet der
// Default "de". Die Funktion ist rein (kein globaler Zustand, keine IO).
func Detect(text string) string {
// Detect returns "de" or "en" for text. Umlauts and ß count as strong German
// signals; otherwise common-word hits decide. English wins only with strictly
// more English than German hits; ties and undecidable text (empty, numbers,
// punctuation) return fallback, which callers take from the configured
// default language.
func Detect(text, fallback string) string {
var de, en int
for _, r := range text {
switch r {
@@ -33,14 +35,18 @@ func Detect(text string) string {
}
}
if en > de {
switch {
case en > de:
return "en"
case de > en:
return "de"
default:
return fallback
}
return "de"
}
// germanWords sind häufige deutsche Wörter, die im Englischen nicht
// vorkommen (Mehrdeutigkeiten wie „was" und „in" wurden weggelassen).
// germanWords are common German words that do not occur in English
// (ambiguous ones such as "was" and "in" are left out on purpose).
var germanWords = map[string]struct{}{
"der": {}, "die": {}, "das": {}, "und": {}, "nicht": {}, "ist": {},
"ein": {}, "eine": {}, "einen": {}, "einem": {}, "einer": {},
@@ -58,8 +64,7 @@ var germanWords = map[string]struct{}{
"wurden": {}, "haben": {}, "geht": {}, "machen": {},
}
// englishWords sind häufige englische Wörter, die im Deutschen nicht
// vorkommen.
// englishWords are common English words that do not occur in German.
var englishWords = map[string]struct{}{
"the": {}, "is": {}, "not": {}, "and": {}, "with": {}, "for": {},
"on": {}, "of": {}, "to": {}, "from": {}, "by": {}, "at": {},
+26 -24
View File
@@ -2,36 +2,38 @@ package langdetect
import "testing"
// TestDetect ist tischgesteuert: Eingabetext → erwartete Sprache.
// Der Default "de" gilt für leeren, unentscheidbaren und gemischten
// Text ohne englische Mehrheit.
// TestDetect is table-driven: input text, expected language. The fallback
// applies to empty, undecidable and tied text; it is passed explicitly so
// the test pins both directions.
func TestDetect(t *testing.T) {
cases := []struct {
name string
text string
want string
name string
text string
fallback string
want string
}{
{"leer", "", "de"},
{"nur whitespace", " \n\t ", "de"},
{"nur interpunktion", "!!! ??? ...", "de"},
{"deutsch satz", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "de"},
{"deutsch frage mit was", "Was macht er?", "de"},
{"deutsch umlaute", "Größe früh Ärzteübung — schön daß es klappt", "de"},
{"ß alleine", "weiß", "de"},
{"englisch satz", "The build has finished and the deployment is done.", "en"},
{"englisch imperativ", "Please wait until the tests are green.", "en"},
{"englisch kurz", "done", "en"},
{"gemischt englisch dominiert", "the build is done and ready for you", "en"},
{"gemischt deutsch dominiert", "Der build ist done und fertig für dich", "de"},
{"gemischt gleichstand", "der the", "de"},
{"unklare sprache", "Build 2026-10-18 14:22 UTC", "de"},
{"agenten-typisch en", "Task completed. Please review the changes and approve.", "en"},
{"agenten-typisch de", "Aufgabe abgeschlossen. Bitte prüfen und freigeben.", "de"},
{"empty → fallback de", "", "de", "de"},
{"empty → fallback en", "", "en", "en"},
{"whitespace only", " \n\t ", "en", "en"},
{"punctuation only", "!!! ??? ...", "de", "de"},
{"german sentence", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "en", "de"},
{"undecidable german question → fallback", "Was macht er?", "en", "en"},
{"german umlauts", "Größe früh Ärzteübung — schön daß es klappt", "en", "de"},
{"ß alone", "weiß", "en", "de"},
{"english sentence", "The build has finished and the deployment is done.", "de", "en"},
{"english imperative", "Please wait until the tests are green.", "de", "en"},
{"english single word", "done", "de", "en"},
{"mixed, english dominates", "the build is done and ready for you", "de", "en"},
{"mixed, german dominates", "Der build ist done und fertig für dich", "en", "de"},
{"tie → fallback", "der the", "en", "en"},
{"undecidable → fallback", "Build 2026-10-18 14:22 UTC", "de", "de"},
{"agent-style en", "Task completed. Please review the changes and approve.", "de", "en"},
{"agent-style de", "Aufgabe abgeschlossen. Bitte prüfen und freigeben.", "en", "de"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Detect(tc.text); got != tc.want {
t.Errorf("Detect(%q) = %q, want %q", tc.text, got, tc.want)
if got := Detect(tc.text, tc.fallback); got != tc.want {
t.Errorf("Detect(%q, %q) = %q, want %q", tc.text, tc.fallback, got, tc.want)
}
})
}
-4
View File
@@ -1,4 +0,0 @@
// Package langdetect erkennt die Sprache eines Ansage-Texts als reine
// Funktion Detect(text) → "de" | "en" (Default "de"; Heuristik über
// Common-Word-Listen und Umlaute/ß).
package langdetect
+21 -21
View File
@@ -1,13 +1,13 @@
// Package notify zeigt Desktop-Benachrichtigungen — best effort, über die
// Bordmittel des Betriebssystems, ohne Bibliotheks-Abhängigkeit und ohne
// CGO (dieselbe Linie wie provider/osbuiltin):
// Package notify shows desktop notifications: best effort, through the
// operating system's own tools, without a library dependency and without
// CGO (the same line as provider/osbuiltin):
//
// Linux: notify-send (libnotify; braucht einen Notification-Daemon)
// Linux: notify-send (libnotify; needs a notification daemon)
// macOS: osascript `display notification`
// Windows: PowerShell Toast (Windows.UI.Notifications)
// Windows: PowerShell toast (Windows.UI.Notifications)
//
// Fehlende Werkzeuge sind ein klarer Fehler für den Aufrufer (Protokoll),
// nie ein Abbruch: Eine Benachrichtigung ist Zusatz, kein Vertrag.
// Missing tools are a clear error for the caller (log), never an abort: a
// notification is an extra, not a contract.
package notify
import (
@@ -23,30 +23,30 @@ import (
"unicode/utf16"
)
// Title ist der Titel aller talk-Benachrichtigungen.
// Title is the title of all talk notifications.
const Title = "talk"
// timeout begrenzt den Aufruf des OS-Werkzeugs.
// timeout bounds the call of the OS tool.
const timeout = 10 * time.Second
// Available meldet, ob auf diesem System ein Benachrichtigungs-Weg
// existiert (Binary vorhanden; ob ein Daemon lauscht, zeigt erst Show).
// Available reports whether a notification path exists on this system
// (binary present; whether a daemon listens only Show reveals).
func Available() bool {
_, err := exec.LookPath(tool())
return err == nil
}
// Describe nennt den Weg für `talk doctor`.
// Describe names the path for `talk doctor`.
func Describe() string {
t := tool()
if _, err := exec.LookPath(t); err != nil {
return t + " nicht gefunden"
return t + " not found"
}
switch runtime.GOOS {
case "darwin":
return "osascript (Notification Center)"
case "windows":
return "powershell (Windows Toast)"
return "powershell (Windows toast)"
default:
return "notify-send (libnotify)"
}
@@ -63,8 +63,8 @@ func tool() string {
}
}
// Show zeigt eine Benachrichtigung mit Title und text und wartet auf das
// Ende des OS-Aufrufs (nicht auf das Schließen der Benachrichtigung).
// Show shows a notification with Title and text and waits for the OS call
// to finish (not for the notification to be closed).
func Show(ctx context.Context, text string) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@@ -79,18 +79,18 @@ func Show(ctx context.Context, text string) error {
cmd = exec.CommandContext(ctx, "notify-send", "--app-name="+Title, "--urgency=normal", Title, text)
}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("notify: %s fehlgeschlagen: %w: %s", tool(), err, bytes.TrimSpace(out))
return fmt.Errorf("notify: %s failed: %w: %s", tool(), err, bytes.TrimSpace(out))
}
return nil
}
// appleString macht aus s ein AppleScript-Stringliteral.
// appleString turns s into an AppleScript string literal.
func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// toastScript ist der PowerShell-Toast (Windows 10/11); der Text wird
// base64 übergeben, damit es kein Quoting-Problem gibt.
// toastScript is the PowerShell toast (Windows 10/11); the text is passed
// base64-encoded so there is no quoting problem.
func toastScript(text string) string {
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
@@ -103,7 +103,7 @@ $nodes.Item(1).AppendChild($xml.CreateTextNode($msg)) | Out-Null
`, base64.StdEncoding.EncodeToString([]byte(text)), Title, Title)
}
// psEncode kodiert ein Skript für -EncodedCommand (UTF-16LE + Base64).
// psEncode encodes a script for -EncodedCommand (UTF-16LE + base64).
func psEncode(script string) string {
runes := utf16.Encode([]rune(script))
buf := make([]byte, len(runes)*2)
+15 -15
View File
@@ -9,12 +9,12 @@ import (
"testing"
)
// TestShowRuftNotifySend: Auf Linux geht Show über notify-send — hier ein
// Fake im PATH, der seine Argumente protokolliert (echter Prozess, kein
// Mock-Framework). Titel und Text kommen unverändert an.
func TestShowRuftNotifySend(t *testing.T) {
// TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a
// fake in PATH that records its arguments (a real process, no mock
// framework). Title and text arrive unchanged.
func TestShowCallsNotifySend(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("notify-send-Weg gibt es nur auf Linux")
t.Skip("the notify-send path exists only on Linux")
}
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
@@ -25,9 +25,9 @@ func TestShowRuftNotifySend(t *testing.T) {
t.Setenv("PATH", dir)
if !Available() {
t.Fatal("Available = false mit Fake im PATH")
t.Fatal("Available = false with fake in PATH")
}
if err := Show(context.Background(), "Ruhezeit: Build fertig"); err != nil {
if err := Show(context.Background(), "quiet hours: build done"); err != nil {
t.Fatalf("Show: %v", err)
}
b, err := os.ReadFile(seen)
@@ -35,27 +35,27 @@ func TestShowRuftNotifySend(t *testing.T) {
t.Fatal(err)
}
args := strings.Split(strings.TrimSpace(string(b)), "\n")
if args[len(args)-1] != "Ruhezeit: Build fertig" || args[len(args)-2] != Title {
t.Errorf("notify-send-Argumente = %q, want … %q %q", args, Title, "Ruhezeit: Build fertig")
if args[len(args)-1] != "quiet hours: build done" || args[len(args)-2] != Title {
t.Errorf("notify-send arguments = %q, want ... %q %q", args, Title, "quiet hours: build done")
}
}
// TestShowOhneWerkzeug: fehlt das Werkzeug, ist das ein klarer Fehler.
func TestShowOhneWerkzeug(t *testing.T) {
// TestShowWithoutTool: a missing tool is a clear error.
func TestShowWithoutTool(t *testing.T) {
t.Setenv("PATH", t.TempDir())
if Available() {
t.Fatal("Available = true ohne Werkzeug")
t.Fatal("Available = true without tool")
}
if err := Show(context.Background(), "x"); err == nil {
t.Error("Show ohne Werkzeug: kein Fehler")
t.Error("Show without tool: no error")
}
if !strings.Contains(Describe(), "nicht gefunden") {
if !strings.Contains(Describe(), "not found") {
t.Errorf("Describe = %q", Describe())
}
}
func TestAppleString(t *testing.T) {
if got := appleString(`sag "hallo" \ tschüss`); got != `"sag \"hallo\" \\ tschüss"` {
if got := appleString(`say "hello" \ bye`); got != `"say \"hello\" \\ bye"` {
t.Errorf("appleString = %s", got)
}
}
+14 -14
View File
@@ -2,10 +2,10 @@ package provider
import "sort"
// Voice ist ein Eintrag des Stimmen-Katalogs: eine bekannte Stimme eines
// Providers für eine Sprache, mit lesbarem Namen. Der Katalog ist kuratiert
// (die gängigen Standardstimmen), nicht vollständig — jede Voice-ID des
// Providers funktioniert auch ohne Katalog-Eintrag (--voice, config).
// Voice is an entry of the voice catalog: a known voice of a provider for a
// language, with a readable name. The catalog is curated (the common
// standard voices), not complete; any voice ID of the provider also works
// without a catalog entry (--voice, config).
type Voice struct {
Provider string
Lang string // de | en
@@ -13,10 +13,10 @@ type Voice struct {
Name string
}
// catalog sind die bekannten Stimmen je Provider. ElevenLabs: öffentliche
// Premade-Stimmen (multilingual, daher für de UND en gelistet). OpenAI: die
// Stimmen der TTS-API (sprachunabhängig). Edge: Neural-Stimmen der
// jeweiligen Locale. osbuiltin kennt keine IDs (das OS wählt).
// catalog lists the known voices per provider. ElevenLabs: public premade
// voices (multilingual, hence listed for both de AND en). OpenAI: the voices
// of the TTS API (language independent). Edge: neural voices of the
// respective locale. osbuiltin has no IDs (the OS chooses).
var catalog = []Voice{
{"elevenlabs", "de", "21m00Tcm4TlvDq8ikWAM", "Rachel"},
{"elevenlabs", "de", "pNInz6obpgDQGcFmaJgB", "Adam"},
@@ -54,8 +54,8 @@ var catalog = []Voice{
{"edge", "en", "en-GB-RyanNeural", "Ryan (GB)"},
}
// Catalog liefert den Stimmen-Katalog in stabiler Reihenfolge
// (Ketten-Reihenfolge der Provider, dann de vor en, dann Katalog-Reihe).
// Catalog returns the voice catalog in stable order (chain order of the
// providers, then de before en, then catalog order).
func Catalog() []Voice {
out := make([]Voice, len(catalog))
copy(out, catalog)
@@ -72,7 +72,7 @@ func Catalog() []Voice {
return out
}
// VoiceName liefert den Katalog-Namen einer Voice-ID; leer, wenn unbekannt.
// VoiceName returns the catalog name of a voice ID; empty if unknown.
func VoiceName(provider, id string) string {
for _, v := range catalog {
if v.Provider == provider && v.ID == id {
@@ -82,9 +82,9 @@ func VoiceName(provider, id string) string {
return ""
}
// DefaultVoice liefert die effektive Default-Stimme eines Providers für
// eine Sprache: config-Mapping voice.<provider>.<lang> vor dem Repo-Default
// (ohne --voice-Pin). Leer bei osbuiltin (das OS wählt).
// DefaultVoice returns the effective default voice of a provider for a
// language: config mapping voice.<provider>.<lang> before the repo default
// (without a --voice pin). Empty for osbuiltin (the OS chooses).
func DefaultVoice(provider, lang string, cfgVoices map[string]map[string]string) string {
return resolveVoice(provider, lang, "", cfgVoices[provider])
}
+105 -108
View File
@@ -9,69 +9,68 @@ import (
"time"
)
// DefaultOrder ist die bindende Default-Fallback-Reihenfolge der Kette
// (Spec: ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in).
// DefaultOrder is the binding default fallback order of the chain
// (spec: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in).
var DefaultOrder = []string{"elevenlabs", "openai", "edge", "osbuiltin"}
// NullPlayerName ist der dokumentierte Name des verwerfenden Sinks
// (Spec: audio.sink = "oto" | "null"). Die Kette braucht ihn für die
// Entscheidung, ob eine Direktansage (DirectSpeaker) verworfen werden
// muss — der null-Sink darf niemals Ton erzeugen.
// NullPlayerName is the documented name of the discarding sink
// (spec: audio.sink = "oto" | "null"). The chain needs it to decide
// whether a direct announcement (DirectSpeaker) must be discarded; the
// null sink must never produce sound.
const NullPlayerName = "null"
// Player spielt Audio-Bytes ab, die die Kette geliefert hat. Der Vertrag
// ist absichtlich strukturell identisch zu audio.Sink (Play + Name): CLI
// und Daemon (Task 04) übergeben das Ergebnis von audio.NewSink(name, log)
// unverändert — dadurch bleibt die Kette ohne Import des audio-Pakets
// testbar und wiederverwendbar.
// Player plays audio bytes the chain has produced. The contract is
// deliberately structurally identical to audio.Sink (Play + Name): CLI and
// daemon pass the result of audio.NewSink(name, log) unchanged, which keeps
// the chain testable and reusable without importing the audio package.
type Player interface {
Play(ctx context.Context, data []byte) error
Name() string
}
// ChainConfig ist alles, was die Kette zum Bau braucht. CLI (Task 03) und
// Daemon (Task 04) füllen sie aus Config + Flags; die Kette selbst liest
// keine Dateien und keine Prozess-Env — Getenv ist die injizierbare Seam
// (Tests und Daemon kommen so ohne echte Keys aus).
// ChainConfig is everything the chain needs for construction. CLI and
// daemon fill it from config + flags; the chain itself reads no files and
// no process environment. Getenv is the injectable seam (tests and daemon
// thus work without real keys).
type ChainConfig struct {
// Pin reduziert die Kette auf genau einen Provider (CLI-Flag --provider
// schlägt config `provider = "…"`). Leer = DefaultOrder.
// Pin reduces the chain to exactly one provider (CLI flag --provider
// beats config `provider = "…"`). Empty = DefaultOrder.
Pin string
// Voice ist eine explizite Voice-ID (CLI-Flag --voice); sie wird über
// VoicePinner an alle Stufen gepinnt und schlägt Mapping + Defaults.
// Voice is an explicit voice ID (CLI flag --voice); it is pinned on all
// tiers via VoicePinner and beats mapping + defaults.
Voice string
// Voices ist das voice.<provider>.<lang>-Mapping aus der config.toml.
// Voices is the voice.<provider>.<lang> mapping from config.toml.
Voices map[string]map[string]string
// Endpoints sind die Datei-Endpunkte [providers.<name>].endpoint;
// TALK_<NAME>_ENDPOINT (via Getenv) schlägt sie, sie schlägt die
// eingebauten Defaults (siehe endpointFrom).
// Endpoints are the file endpoints [providers.<name>].endpoint;
// TALK_<NAME>_ENDPOINT (via Getenv) beats them, they beat the built-in
// defaults (see endpointFrom).
Endpoints map[string]string
// Model ist [providers.openai].model (leer = "tts-1").
// Model is [providers.openai].model (empty = "tts-1").
Model string
// Getenv liefert Umgebungsvariablen (Keys, Endpunkt-Overrides). Nil
// wird beim Bau durch eine Funktion ersetzt, die immer "" liefert.
// Getenv returns environment variables (keys, endpoint overrides). Nil
// is replaced at construction by a function that always returns "".
Getenv func(string) string
// Player gibt erfolgreiche Synthesen aus; nil = die Kette liefert nur
// Bytes (Synthesize) und Speak meldet einen klaren Fehler.
// Player plays successful syntheses; nil = the chain only returns bytes
// (Synthesize) and Speak reports a clear error.
Player Player
// Log empfängt die Fallback-Protokoll-Zeilen (CLI: stderr). Fallbacks
// sind nie still: Jede übersprungene oder fehlgeschlagene Stufe wird
// hier gemeldet — auch wenn am Ende noch eine Stufe Erfolg hat.
// Log receives the fallback log lines (CLI: stderr). Fallbacks are never
// silent: every skipped or failed tier is reported here, even when a
// later tier succeeds.
Log io.Writer
}
// endpointFrom löst einen Provider-Endpunkt auf:
// TALK_<NAME>_ENDPOINT (Env) > [providers.<name>].endpoint (Datei) >
// eingebauter Default. Env-Overrides in der Provider-Schicht zu lesen hält
// das config-Paket frei von Provider-Namen und erlaubt Prozessgrenzen-
// Tests ohne Config-Datei.
// endpointFrom resolves a provider endpoint:
// TALK_<NAME>_ENDPOINT (env) > [providers.<name>].endpoint (file) >
// built-in default. Reading env overrides in the provider layer keeps the
// config package free of provider names and allows process-boundary tests
// without a config file.
func endpointFrom(cfg ChainConfig, name, envName, def string) string {
if cfg.Getenv != nil {
if v := cfg.Getenv(envName); v != "" {
@@ -84,25 +83,25 @@ func endpointFrom(cfg ChainConfig, name, envName, def string) string {
return def
}
// Chain ist die geordnete Fallback-Kette über Provider. Sie implementiert
// selbst Provider (Synthesize liefert die Bytes der ersten erfolgreichen
// Stufe) UND DirectSpeaker (letzter Stufen-Ausweg für Plattformen ohne
// Byte-fähiges TTS) — damit ist sie für CLI und Daemon (Task 04)
// austauschbar gegen jeden einzelnen Provider.
// Chain is the ordered fallback chain over providers. It implements
// Provider itself (Synthesize returns the bytes of the first successful
// tier) AND DirectSpeaker (last-resort tier for platforms without
// byte-capable TTS), so CLI and daemon can swap it for any single provider.
//
// Speak ist die komplette Ansage-Orchestrierung: Bytes-Synthese mit
// Fallback, Wiedergabe über den Player, und als letzte Stufe die
// Direktansage (osbuiltin über spd-say). Bei einem null-Player wird die
// Direktansage tonlos verworfen (CI/Server) statt Ton zu erzeugen.
// Speak is the complete announcement orchestration: byte synthesis with
// fallback, playback via the Player, and as the last tier the direct
// announcement (osbuiltin via spd-say). With a null player the direct
// announcement is discarded silently (CI/server) instead of producing
// sound.
type Chain struct {
cfg ChainConfig
tiers []Provider
}
// NewChain baut die Kette: gepinnt (Pin) oder in DefaultOrder. Unbekannte
// Namen sind ein klarer Fehler, der die verfügbaren Namen nennt. Das
// --voice-Pin wird auf alle Stufen angewandt, die VoicePinner implementieren
// (Stufen ohne Voice-Begriff bekommen einen protokollierten Hinweis).
// NewChain builds the chain: pinned (Pin) or in DefaultOrder. Unknown names
// are a clear error that lists the available names. The --voice pin is
// applied to all tiers implementing VoicePinner (tiers without a notion of
// voice get a logged note).
func NewChain(cfg ChainConfig) (*Chain, error) {
if cfg.Getenv == nil {
cfg.Getenv = func(string) string { return "" }
@@ -121,7 +120,7 @@ func NewChain(cfg ChainConfig) (*Chain, error) {
if vp, ok := p.(VoicePinner); ok {
vp.PinVoice(cfg.Voice)
} else {
chainLogf(cfg, "kette: --voice für Stufe %s ignoriert (kennt keine Voice-IDs)\n", name)
chainLogf(cfg, "chain: --voice ignored for tier %s (it has no voice IDs)\n", name)
}
}
tiers = append(tiers, p)
@@ -129,13 +128,13 @@ func NewChain(cfg ChainConfig) (*Chain, error) {
return &Chain{cfg: cfg, tiers: tiers}, nil
}
// Name implementiert Provider.
// Name implements Provider.
func (*Chain) Name() string { return "chain" }
// Tiers liefert die Stufen in Ketten-Reihenfolge (Diagnose/doctor).
// Tiers returns the tiers in chain order (diagnosis/doctor).
func (c *Chain) Tiers() []Provider { return c.tiers }
// Available implementiert Provider: true, sobald eine Stunde verfügbar ist.
// Available implements Provider: true as soon as one tier is available.
func (c *Chain) Available(ctx context.Context) bool {
for _, p := range c.tiers {
if p.Available(ctx) {
@@ -145,55 +144,54 @@ func (c *Chain) Available(ctx context.Context) bool {
return false
}
// walkResult ist das interne Ergebnis des Ketten-Durchlaufs.
// walkResult is the internal result of a chain walk.
type walkResult struct {
data []byte // Bytes der ersten erfolgreichen Stufe (nil = keine)
used Provider // die erfolgreiche Stufe
direct DirectSpeaker // erste Stufe, die nur direkt sprechen kann
data []byte // bytes of the first successful tier (nil = none)
used Provider // the successful tier
direct DirectSpeaker // first tier that can only speak directly
directName string
failed []string // "name: fehler" je fehlgeschlagener Stufe
skipped []string // Namen nicht verfügbarer Stufen
failed []string // "name: error" per failed tier
skipped []string // names of unavailable tiers
}
// walk läuft die Kette in Ordnung ab: nicht verfügbare Stufen werden
// übersprungen (protokolliert), fehlgeschlagene protokolliert und
// überwunden. Eine Stufe mit ErrNoByteOutput, die DirectSpeaker
// implementiert, wird als letzter Ausweg gemerkt (der Durchlauf geht
// weiter — eine spätere Stufe könnte noch Bytes liefern).
// walk runs the chain in order: unavailable tiers are skipped (logged),
// failed ones are logged and passed over. A tier returning ErrNoByteOutput
// that implements DirectSpeaker is remembered as the last resort (the walk
// continues; a later tier might still deliver bytes).
func (c *Chain) walk(ctx context.Context, text, lang string) walkResult {
var r walkResult
for _, p := range c.tiers {
if !p.Available(ctx) {
r.skipped = append(r.skipped, p.Name())
chainLogf(c.cfg, "kette: Stufe %s übersprungen (nicht verfügbar)\n", p.Name())
chainLogf(c.cfg, "chain: tier %s skipped (not available)\n", p.Name())
continue
}
data, err := p.Synthesize(ctx, text, lang)
switch {
case err == nil:
r.data, r.used = data, p
chainLogf(c.cfg, "kette: Ansage über %s\n", p.Name())
chainLogf(c.cfg, "chain: spoken via %s\n", p.Name())
return r
case errors.Is(err, ErrNoByteOutput) && r.direct == nil:
if ds, ok := p.(DirectSpeaker); ok {
r.direct, r.directName = ds, p.Name()
chainLogf(c.cfg, "kette: Stufe %s liefert keine Bytes, kann aber direkt sprechen (als letzte Stufe gemerkt)\n", p.Name())
chainLogf(c.cfg, "chain: tier %s yields no bytes but can speak directly (kept as last resort)\n", p.Name())
continue
}
r.failed = append(r.failed, p.Name()+": "+err.Error())
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
default:
r.failed = append(r.failed, p.Name()+": "+err.Error())
chainLogf(c.cfg, "kette: Stufe %s fehlgeschlagen: %v — Fallback auf nächste Stufe\n", p.Name(), err)
chainLogf(c.cfg, "chain: tier %s failed: %v; falling back to the next tier\n", p.Name(), err)
}
}
return r
}
// Synthesize implementiert Provider: Bytes der ersten erfolgreichen Stufe.
// Kann nur eine Direkt-Stufe sprechen, ist der Fehler ErrNoByteOutput (die
// Kette erfüllt damit den DirectSpeaker-Vertrag am Aufrufer). Scheitert
// alles, nennt der Fehler die letzte Stufen-Fehler — nie still.
// Synthesize implements Provider: bytes of the first successful tier. If
// only a direct tier can speak, the error is ErrNoByteOutput (the chain thus
// honours the DirectSpeaker contract towards the caller). If everything
// fails, the error names the tier errors; never silent.
func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
r := c.walk(ctx, text, lang)
if r.data != nil {
@@ -201,50 +199,49 @@ func (c *Chain) Synthesize(ctx context.Context, text, lang string) ([]byte, erro
}
if r.direct != nil {
if len(r.failed) > 0 {
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt; daneben fehlgeschlagen: %s)",
return nil, fmt.Errorf("%w (tier %s speaks directly only; also failed: %s)",
ErrNoByteOutput, r.directName, strings.Join(r.failed, "; "))
}
return nil, fmt.Errorf("%w (Stufe %s spricht nur direkt)", ErrNoByteOutput, r.directName)
return nil, fmt.Errorf("%w (tier %s speaks directly only)", ErrNoByteOutput, r.directName)
}
return nil, r.noTierErr()
}
// SpeakDirect implementiert DirectSpeaker: Direktansage über die Stufe,
// die nur direkt sprechen kann (typischerweise osbuiltin/spd-say auf Linux
// ohne espeak). Gibt es eine Byte-fähige Stufe, ist SpeakDirect ein klarer
// Fehler (der Aufrufer soll Synthesize + Player nutzen).
// SpeakDirect implements DirectSpeaker: direct announcement via the tier
// that can only speak directly (typically osbuiltin/spd-say on Linux
// without espeak). If a byte-capable tier exists, SpeakDirect is a clear
// error (the caller should use Synthesize + Player).
func (c *Chain) SpeakDirect(ctx context.Context, text, lang string) error {
r := c.walk(ctx, text, lang)
if r.direct != nil {
return r.direct.SpeakDirect(ctx, text, lang)
}
if r.data != nil {
return fmt.Errorf("kette: Stufe %s liefert Bytes — über Synthesize und den Player abspielen, nicht direkt", r.used.Name())
return fmt.Errorf("chain: tier %s yields bytes; play them via Synthesize and the player, not directly", r.used.Name())
}
return fmt.Errorf("kette: keine Stufe kann direkt sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return fmt.Errorf("chain: no tier can speak directly (not available: %s; failed: %s)",
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
}
// Speak ist die komplette Ansage in EINEM Ketten-Durchlauf: Synthese mit
// Fallback → Wiedergabe über den Player; fällt die Kette auf die Direkt-
// Stufe zurück, entscheidet der Player-Name über Ton (alles außer „null")
// oder tonloses Verwerfen (null: CI/Server bleiben fehler- und tonfrei,
// der Verwurf wird protokolliert). Der Durchlauf findet bewusst genau
// einmal statt: fehlgeschlagene Cloud-Stufen werden nicht ein zweites Mal
// angefragt (keine doppelten POSTs auf bezahlten APIs, keine doppelten
// Protokoll-Zeilen), und Bytes aus diesem Durchlauf werden abgespielt,
// statt sie wegzuwerfen und neu zu laufen. Nur ein Fehler aller Stufen
// oder eine fehlgeschlagene Wiedergabe sind Fehler.
// Speak is the complete announcement in ONE chain walk: synthesis with
// fallback → playback via the Player; if the chain falls back to the direct
// tier, the player name decides between sound (anything but "null") and
// silent discarding (null: CI/server stay error- and sound-free, the
// discard is logged). The walk deliberately happens exactly once: failed
// cloud tiers are not requested a second time (no duplicate POSTs to paid
// APIs, no duplicate log lines), and bytes from this walk are played
// instead of being thrown away and re-run. Only a failure of all tiers or a
// failed playback are errors.
func (c *Chain) Speak(ctx context.Context, text, lang string) error {
_, err := c.SpeakOutcome(ctx, text, lang)
return err
}
// Outcome beschreibt eine gelungene Ansage: welche Stufe gesprochen hat,
// wie lange Synthese (Ketten-Durchlauf bis zu den Bytes) und Wiedergabe
// gedauert haben, und ob es eine Direktansage war (keine Bytes, Provider
// spielt selbst — dann ist Synthesis die Gesamtzeit und Playback 0).
// Discarded meldet die tonlose Verwerfung einer Direktansage am null-Sink.
// Outcome describes a successful announcement: which tier spoke, how long
// synthesis (chain walk up to the bytes) and playback took, and whether it
// was a direct announcement (no bytes, the provider plays itself; then
// Synthesis is the total time and Playback is 0). Discarded reports the
// silent discarding of a direct announcement on the null sink.
type Outcome struct {
Provider string
Direct bool
@@ -254,7 +251,7 @@ type Outcome struct {
Playback time.Duration
}
// SpeakOutcome ist Speak mit Bericht (Diagnose: `talk test`, Daemon-Log).
// SpeakOutcome is Speak with a report (diagnosis: `talk test`, daemon log).
func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, error) {
start := time.Now()
r := c.walk(ctx, text, lang)
@@ -262,16 +259,16 @@ func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, e
switch {
case r.data != nil:
if c.cfg.Player == nil {
return Outcome{}, fmt.Errorf("kette: kein Player konfiguriert — Bytes können nicht abgespielt werden")
return Outcome{}, fmt.Errorf("chain: no player configured; bytes cannot be played")
}
playStart := time.Now()
if err := c.cfg.Player.Play(ctx, r.data); err != nil {
return Outcome{}, fmt.Errorf("kette: Wiedergabe über %s fehlgeschlagen: %w", c.cfg.Player.Name(), err)
return Outcome{}, fmt.Errorf("chain: playback via %s failed: %w", c.cfg.Player.Name(), err)
}
return Outcome{Provider: r.used.Name(), Bytes: len(r.data), Synthesis: synth, Playback: time.Since(playStart)}, nil
case r.direct != nil:
if c.cfg.Player != nil && c.cfg.Player.Name() == NullPlayerName {
chainLogf(c.cfg, "kette: Direktansage verworfen (audio.sink=null)\n")
chainLogf(c.cfg, "chain: direct speech discarded (audio.sink=null)\n")
return Outcome{Provider: r.directName, Direct: true, Discarded: true, Synthesis: synth}, nil
}
if err := r.direct.SpeakDirect(ctx, text, lang); err != nil {
@@ -283,22 +280,22 @@ func (c *Chain) SpeakOutcome(ctx context.Context, text, lang string) (Outcome, e
}
}
// noTierErr bündelt den Ende-ohne-Ergebnis-Fehler eines Durchlaufs: keine
// Stufe lieferte Bytes oder Direktansage — übersprungene (nicht verfügbar)
// und fehlgeschlagene Stufen werden genannt, nie still.
// noTierErr bundles the no-result error of a walk: no tier delivered bytes
// or a direct announcement; skipped (unavailable) and failed tiers are
// named, never silent.
func (r walkResult) noTierErr() error {
return fmt.Errorf("kette: keine Stufe konnte sprechen (nicht verfügbar: %s; fehlgeschlagen: %s)",
joinOr(r.skipped, "keine"), joinOr(r.failed, "keine"))
return fmt.Errorf("chain: no tier could speak (not available: %s; failed: %s)",
joinOr(r.skipped, "none"), joinOr(r.failed, "none"))
}
// chainLogf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist.
// chainLogf writes a log line if a log is set.
func chainLogf(cfg ChainConfig, format string, args ...any) {
if cfg.Log != nil {
fmt.Fprintf(cfg.Log, format, args...)
}
}
// joinOr verbindet Einträge mit sep; ist die Liste leer, kommt fallback.
// joinOr joins items with "; "; if the list is empty, fallback is returned.
func joinOr(items []string, fallback string) string {
if len(items) == 0 {
return fallback
+104 -105
View File
@@ -13,8 +13,8 @@ import (
"testing"
)
// fakePlayer zeichnet Play-Aufrufe auf (Name steuert die null-Entscheidung
// der Kette).
// fakePlayer records Play calls (the name drives the chain's null
// decision).
type fakePlayer struct {
name string
played [][]byte
@@ -26,8 +26,8 @@ func (f *fakePlayer) Play(_ context.Context, data []byte) error {
}
func (f *fakePlayer) Name() string { return f.name }
// countingTTSServer antwortet mit status + body und zählt die Requests
// (atomic, der Server kann mehrere Stufen-Requests sehen).
// countingTTSServer responds with status + body and counts the requests
// (atomic; the server may see requests from several tiers).
func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits *atomic.Int32) {
t.Helper()
var n atomic.Int32
@@ -40,13 +40,13 @@ func countingTTSServer(t *testing.T, status int, body []byte) (url string, hits
return ts.URL, &n
}
// chainEnv baut die Getenv-Seam für Tests mit gefakten Cloud-Endpunkten.
// chainEnv builds the Getenv seam for tests with faked cloud endpoints.
func chainEnv(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeProcBin schreibt ein ausführbares Shell-Skript (gleicher Mechanismus
// wie in osbuiltin_test.go — echter Prozess, PATH-Manipulation).
// fakeProcBin writes an executable shell script (same mechanism as in
// osbuiltin_test.go: real process, PATH manipulation).
func fakeProcBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -55,9 +55,9 @@ func fakeProcBin(t *testing.T, dir, name, body string) {
}
}
// cloudEnv liefert die Env-Map für eine Kette, deren elevenlabs-Stufe auf
// elevenURL und deren openai-Stufe auf openaiURL zeigt (edge tot, damit
// kein Test den echten Microsoft-Endpunkt anfasst).
// cloudEnv returns the env map for a chain whose elevenlabs tier points at
// elevenURL and whose openai tier points at openaiURL (edge is dead so that
// no test touches the real Microsoft endpoint).
func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]string {
kv := map[string]string{
EnvElevenLabsKey: "k",
@@ -72,11 +72,10 @@ func cloudEnv(elevenURL, openaiURL string, extra map[string]string) map[string]s
return kv
}
// TestChainFallbackErster500ZweiterTreffer (Abnahmekriterium aus dem
// Task-Brief): Stufe 1 (elevenlabs) antwortet 500 → die Kette fällt auf
// Stufe 2 (openai) durch, deren Bytes kommen zurück; der Fallback ist im
// Log sichtbar, nie still.
func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
// TestChainFallbackFirst500SecondHit (acceptance criterion): tier 1
// (elevenlabs) answers 500 → the chain falls through to tier 2 (openai),
// whose bytes come back; the fallback is visible in the log, never silent.
func TestChainFallbackFirst500SecondHit(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
openai, oaHits := countingTTSServer(t, 200, fakeMP3)
@@ -89,30 +88,30 @@ func TestChainFallbackErster500ZweiterTreffer(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo Welt", "de")
data, err := ch.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Bytes der openai-Stufe", data)
t.Errorf("Synthesize = %q, want bytes of the openai tier", data)
}
if elHits.Load() != 1 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d, openai = %d — want je 1 (500 → Fallback)", elHits.Load(), oaHits.Load())
t.Errorf("hits: elevenlabs = %d, openai = %d; want 1 each (500 → fallback)", elHits.Load(), oaHits.Load())
}
for _, want := range []string{"elevenlabs", "500", "Fallback", "Ansage über openai"} {
for _, want := range []string{"elevenlabs", "500", "falling back", "spoken via openai"} {
if !strings.Contains(log.String(), want) {
t.Errorf("Log = %q, will %q", log.String(), want)
t.Errorf("log = %q, want %q", log.String(), want)
}
}
}
// TestChainAllesFehlt: beide Cloud-Stufen 500, edge tot, osbuiltin nicht
// verfügbar → Fehler nennt die fehlgeschlagenen Stufen; Log zeigt jeden.
func TestChainAllesFehlt(t *testing.T) {
// TestChainEverythingFails: both cloud tiers 500, edge dead, osbuiltin not
// available → the error names the failed tiers; the log shows each one.
func TestChainEverythingFails(t *testing.T) {
eleven, _ := countingTTSServer(t, 500, []byte("el-boom"))
openai, _ := countingTTSServer(t, 500, []byte("oa-boom"))
// PATH leer: osbuiltin nicht verfügbar.
// Empty PATH: osbuiltin not available.
t.Setenv("PATH", t.TempDir())
var log bytes.Buffer
@@ -124,21 +123,21 @@ func TestChainAllesFehlt(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize mit allen Stufen kaputt: kein Fehler")
t.Fatal("Synthesize with all tiers broken: no error")
}
for _, want := range []string{"elevenlabs", "openai", "osbuiltin", "500"} {
if !strings.Contains(err.Error(), want) && !strings.Contains(log.String(), want) {
t.Errorf("Weder Fehler (%v) noch Log nennen %q", err, want)
t.Errorf("neither error (%v) nor log mention %q", err, want)
}
}
}
// TestChainOhneKeysEdgeOsbuiltin (Abnahmekriterium): ohne jeden Key und
// mit totem edge wählt die Kette osbuiltin — mit gefaktem espeak kommen
// dessen Bytes zurück.
func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
// TestChainWithoutKeysEdgeOsbuiltin (acceptance criterion): without any
// key and with a dead edge, the chain picks osbuiltin; with a faked espeak
// its bytes come back.
func TestChainWithoutKeysEdgeOsbuiltin(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "espeak", `printf 'RIFFxxxxWAVEfmt '`)
t.Setenv("PATH", dir)
@@ -151,20 +150,20 @@ func TestChainOhneKeysEdgeOsbuiltin(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
data, err := ch.Synthesize(context.Background(), "Hallo", "de")
data, err := ch.Synthesize(context.Background(), "Hello", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if !bytes.HasPrefix(data, []byte("RIFF")) {
t.Errorf("Synthesize = %q, want WAV-Bytes des fake espeak", data)
t.Errorf("Synthesize = %q, want WAV bytes of the fake espeak", data)
}
if !strings.Contains(log.String(), "übersprungen (nicht verfügbar)") {
t.Errorf("Log = %q, will Skip-Hinweise für die Key-Stufen", log.String())
if !strings.Contains(log.String(), "skipped (not available)") {
t.Errorf("log = %q, want skip notes for the key tiers", log.String())
}
}
// TestChainPin: --provider/config pinnen die Kette auf genau eine Stufe —
// andere Stufen werden nicht einmal angefragt.
// TestChainPin: --provider/config pin the chain to exactly one tier; other
// tiers are not even requested.
func TestChainPin(t *testing.T) {
eleven, elHits := countingTTSServer(t, 200, fakeMP3)
openai, oaHits := countingTTSServer(t, 500, []byte("boom"))
@@ -176,24 +175,24 @@ func TestChainPin(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err == nil {
t.Fatal("Gepinnte kaputte Stufe soll fehlschlagen (kein Fallback bei Pin)")
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err == nil {
t.Fatal("pinned broken tier should fail (no fallback when pinned)")
}
if elHits.Load() != 0 || oaHits.Load() != 1 {
t.Errorf("Hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
t.Errorf("hits: elevenlabs = %d (want 0), openai = %d (want 1)", elHits.Load(), oaHits.Load())
}
}
// TestChainUnbekannterPin ist ein klarer Fehler mit Namensliste.
func TestChainUnbekannterPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "gibtsnicht"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain-Fehler = %v, will verfügbare Namen nennen", err)
// TestChainUnknownPin is a clear error listing the names.
func TestChainUnknownPin(t *testing.T) {
if _, err := NewChain(ChainConfig{Pin: "doesnotexist"}); err == nil || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("NewChain error = %v, want the available names", err)
}
}
// TestChainSpeakBytesUeberPlayer: erfolgreiche Synthese wird über den
// Player abgespielt.
func TestChainSpeakBytesUeberPlayer(t *testing.T) {
// TestChainSpeakBytesViaPlayer: a successful synthesis is played via the
// Player.
func TestChainSpeakBytesViaPlayer(t *testing.T) {
eleven, _ := countingTTSServer(t, 200, fakeMP3)
player := &fakePlayer{name: "oto"}
ch, err := NewChain(ChainConfig{
@@ -203,22 +202,22 @@ func TestChainSpeakBytesUeberPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo", "de"); err != nil {
if err := ch.Speak(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Speak: %v", err)
}
if len(player.played) != 1 || string(player.played[0]) != string(fakeMP3) {
t.Errorf("Player.Play = %x, want die Synthese-Bytes", player.played)
t.Errorf("Player.Play = %x, want the synthesis bytes", player.played)
}
}
// TestChainDirektMitNullPlayer: fällt die Kette auf die Direkt-Stufe
// (osbuiltin ohne espeak), verwirft ein null-Player die Ansage tonlos und
// SUCCESS — spd-say wird nicht gerufen.
func TestChainDirektMitNullPlayer(t *testing.T) {
// TestChainDirectWithNullPlayer: when the chain falls back to the direct
// tier (osbuiltin without espeak), a null player discards the announcement
// silently and SUCCEEDS; spd-say is not called.
func TestChainDirectWithNullPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
t.Setenv("PATH", dir) // kein espeak → keine Bytes, aber spd-say da
t.Setenv("PATH", dir) // no espeak → no bytes, but spd-say present
var log bytes.Buffer
ch, err := NewChain(ChainConfig{
@@ -230,20 +229,21 @@ func TestChainDirektMitNullPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (null-Player): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (null player): %v", err)
}
if _, err := os.Stat(seen); err == nil {
t.Error("spd-say wurde gerufen — null-Player hätte verwerfen müssen")
t.Error("spd-say was called; the null player should have discarded")
}
if !strings.Contains(log.String(), "verworfen") {
t.Errorf("Log = %q, will Verwurf-Zeile", log.String())
if !strings.Contains(log.String(), "discarded") {
t.Errorf("log = %q, want discard line", log.String())
}
}
// TestChainDirektMitTonPlayer: mit echtem Player (Name ≠ null) läuft die
// Direktansage über SpeakDirect — blockierend (-w) mit Sprache und Text.
func TestChainDirektMitTonPlayer(t *testing.T) {
// TestChainDirectWithSoundPlayer: with a real player (name != null) the
// direct announcement runs via SpeakDirect, blocking (-w) with language and
// text.
func TestChainDirectWithSoundPlayer(t *testing.T) {
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
@@ -258,24 +258,24 @@ func TestChainDirektMitTonPlayer(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct): %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
if len(player.played) != 0 {
t.Errorf("Player.Play wurde gerufen (%x) — Direktansage geht am Player vorbei", player.played)
t.Errorf("Player.Play was called (%x); a direct announcement bypasses the player", player.played)
}
}
// TestChainSynthesizeErrNoByteOutput: Die Kette als Provider meldet
// ErrNoByteOutput, wenn nur eine Direkt-Stufe übrig bleibt — der
// DirectSpeaker-Vertrag bleibt am Aufrufer erhalten.
// TestChainSynthesizeErrNoByteOutput: the chain as a Provider reports
// ErrNoByteOutput when only a direct tier remains; the DirectSpeaker
// contract is preserved towards the caller.
func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
dir := t.TempDir()
fakeProcBin(t, dir, "spd-say", "true")
@@ -285,27 +285,27 @@ func TestChainSynthesizeErrNoByteOutput(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
_, err = ch.Synthesize(context.Background(), "Hallo", "de")
_, err = ch.Synthesize(context.Background(), "Hello", "de")
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
// Chain selbst ist DirectSpeaker.
// Chain itself is a DirectSpeaker.
if _, ok := any(ch).(DirectSpeaker); !ok {
t.Error("Chain implementiert DirectSpeaker nicht")
t.Error("Chain does not implement DirectSpeaker")
}
}
// TestChainAvailableUndNamen: Available true sobald eine Stufe verfügbar;
// Default-Reihenfolge ist die bindende aus der Spec.
func TestChainAvailableUndNamen(t *testing.T) {
// TestChainAvailableAndNames: Available is true as soon as one tier is
// available; the default order is the binding one from the spec.
func TestChainAvailableAndNames(t *testing.T) {
ch, err := NewChain(ChainConfig{Getenv: chainEnv(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com", // edge: immer verfügbar mit Endpunkt
EnvEdgeEndpoint: "wss://edge.example.com", // edge: always available with an endpoint
})})
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if !ch.Available(context.Background()) {
t.Error("Available = false trotz verfügbarer edge-Stufe")
t.Error("Available = false despite an available edge tier")
}
want := []string{"elevenlabs", "openai", "edge", "osbuiltin"}
if len(ch.Tiers()) != len(want) {
@@ -318,9 +318,9 @@ func TestChainAvailableUndNamen(t *testing.T) {
}
}
// TestChainVoicePinDurchgereicht: ChainConfig.Voice wird via VoicePinner an
// die Stufen gepinnt — beobachtbar am Request-Pfad des ElevenLabs-Fakes.
func TestChainVoicePinDurchgereicht(t *testing.T) {
// TestChainVoicePinForwarded: ChainConfig.Voice is pinned on the tiers via
// VoicePinner; observable in the request path of the ElevenLabs fake.
func TestChainVoicePinForwarded(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
ch, err := NewChain(ChainConfig{
Pin: "elevenlabs",
@@ -333,24 +333,23 @@ func TestChainVoicePinDurchgereicht(t *testing.T) {
if err != nil {
t.Fatalf("NewChain: %v", err)
}
if _, err := ch.Synthesize(context.Background(), "Hallo", "de"); err != nil {
if _, err := ch.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/ChainVoice"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
t.Errorf("path = %q, want %q", rec.path, want)
}
}
// TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
// Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
// wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
// keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
// genau einmal, und die Direktansage kommt zustande.
func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
// TestChainDirectEndgameSingleWalk (review fix): in the DirectSpeaker
// endgame the chain walks exactly ONCE; the failed cloud tier is requested
// only once (no duplicate POSTs to paid APIs, no doubled latency), every
// tier logs its failure exactly once, and the direct announcement happens.
func TestChainDirectEndgameSingleWalk(t *testing.T) {
eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
// elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
// übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
// fake spd-say (kein espeak) → keine Bytes, Direktansage.
// elevenlabs: key + fake (500). openai: endpoint but NO key → skipped.
// edge: dead loopback port. osbuiltin: PATH with only a fake spd-say (no
// espeak) → no bytes, direct announcement.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
@@ -361,7 +360,7 @@ func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
Getenv: chainEnv(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: eleven,
EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
EnvOpenAIEndpoint: eleven, // skipped anyway without a key
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
}),
Player: &fakePlayer{name: "oto"},
@@ -371,25 +370,25 @@ func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
t.Fatalf("NewChain: %v", err)
}
if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
t.Fatalf("Speak (Direkt-Endgame): %v", err)
if err := ch.Speak(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("Speak (direct endgame): %v", err)
}
if got := elHits.Load(); got != 1 {
t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
t.Errorf("elevenlabs was requested %d times, want exactly 1 (no second walk)", got)
}
for _, tier := range []string{"elevenlabs", "edge"} {
if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
if got := strings.Count(log.String(), "tier "+tier+" failed"); got != 1 {
t.Errorf("\"tier %s failed\" appears %d times in the log, want exactly 1:\n%s", tier, got, log.String())
}
}
if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
if got := strings.Count(log.String(), "tier openai skipped"); got != 1 {
t.Errorf("openai skip appears %d times in the log, want exactly 1", got)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake spd-say nicht gerufen: %v", err)
t.Fatalf("fake spd-say not called: %v", err)
}
if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
if got := strings.TrimSpace(string(args)); got != "-w -l de Hello world" {
t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hello world")
}
}
+10 -11
View File
@@ -1,12 +1,11 @@
// Package provider implementiert die TTS-Provider von talk und ihre
// Fallback-Kette. Der Vertrag (Name/Available/Synthesize) trägt die
// Vier-Stufen-Kette aus Task 03: ElevenLabs → OpenAI TTS → Edge-TTS →
// OS built-in (überschreibbar per --provider/config provider). Die
// Cloud-Provider liefern MP3 (Keys ausschließlich über TALK_ELEVENLABS_KEY
// bzw. TALK_OPENAI_KEY, Endpunkte konfigurierbar und damit fake-bar);
// osbuiltin liefert RIFF/WAVE-PCM. Die dokumentierte optionale Erweiterung
// DirectSpeaker deckt Plattformen ab, deren OS-TTS keine Audio-Bytes
// liefern kann (Linux speech-dispatcher) — Chain.Speak behandelt diesen
// Fall inklusive tonlosem Verwerfen am null-Sink, damit CLI (Task 03) und
// Daemon (Task 04) dieselbe Kette unverändert nutzen.
// Package provider implements talk's TTS providers and their fallback
// chain. The contract (Name/Available/Synthesize) carries the four-tier
// chain: ElevenLabs → OpenAI TTS → Edge TTS → OS built-in (overridable via
// --provider/config provider). The cloud providers return MP3 (keys come
// exclusively from TALK_ELEVENLABS_KEY and TALK_OPENAI_KEY, endpoints are
// configurable and therefore fakeable); osbuiltin returns RIFF/WAVE PCM.
// The documented optional extension DirectSpeaker covers platforms whose
// OS TTS cannot provide audio bytes (Linux speech-dispatcher); Chain.Speak
// handles that case, including silent discarding on the null sink, so that
// the CLI and the daemon use the same chain unchanged.
package provider
+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 {
+83 -84
View File
@@ -14,72 +14,72 @@ import (
"github.com/gorilla/websocket"
)
// TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
// Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
// gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
// Endpunkt) ist es nicht.
// TestEdgeAvailable: Edge needs only an endpoint, no key. The constructor
// bakes in the default endpoint, so an Edge built via NewEdge is always
// available; only the zero value (built directly, without an endpoint) is
// not.
func TestEdgeAvailable(t *testing.T) {
if (&Edge{}).Available(context.Background()) {
t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
t.Error("Available without endpoint (zero value) = true, want false")
}
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com",
})})
if !p.Available(context.Background()) {
t.Error("Available mit Endpunkt = false, want true")
t.Error("Available with endpoint = false, want true")
}
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
t.Error("Available with default endpoint = false, want true (edge is the key-free fallback)")
}
}
// TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
// Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
// TestEdgeConnectionFails (error-path mock): a guaranteed dead loopback
// port yields a clear error, immediately and without network.
func TestEdgeConnectionFails(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize gegen toten Port: kein Fehler")
t.Fatal("Synthesize against dead port: no error")
}
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
t.Errorf("error = %v, want a clear edge/WebSocket error", err)
}
}
// TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
func TestEdgeUngueltigesSchema(t *testing.T) {
// TestEdgeInvalidScheme: a non-ws(s) endpoint is a clear error.
func TestEdgeInvalidScheme(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), "ws://") {
t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
t.Errorf("error = %v, want scheme hint ws://", err)
}
}
// TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
// befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
// „en-US".
// TestEdgeSSML: the SSML request contains the voice and the text stripped
// of control characters and XML-escaped; xml:lang is fixed to "en-US" as in
// the reference.
func TestEdgeSSML(t *testing.T) {
got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
got := edgeSSML("de-DE-KatjaNeural", "AT&T <customer> „done“")
for _, want := range []string{
"voice name='de-DE-KatjaNeural'",
"xml:lang='en-US'",
"AT&amp;T &lt;Kunde&gt;",
"AT&amp;T &lt;customer&gt;",
} {
if !strings.Contains(got, want) {
t.Errorf("SSML = %q, will %q", got, want)
t.Errorf("SSML = %q, want %q", got, want)
}
}
if strings.Contains(got, "AT&T") {
t.Errorf("SSML enthält unescaptes &: %q", got)
t.Errorf("SSML contains unescaped &: %q", got)
}
}
// TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden
// durch Leerzeichen ersetzt, & < > escapt.
// TestEdgeEscape: control characters (e.g. vertical tab from OCR texts) are
// replaced by spaces, & < > are escaped.
func TestEdgeEscape(t *testing.T) {
in := "a\x0bb&c<d>\x08e"
if want := "a b&amp;c&lt;d&gt; e"; edgeEscape(in) != want {
@@ -87,19 +87,19 @@ func TestEdgeEscape(t *testing.T) {
}
}
// TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen.
// TestEdgeSpeechConfigMsg: output format (mono mp3) and path are correct.
func TestEdgeSpeechConfigMsg(t *testing.T) {
msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
if !strings.Contains(msg, "Path:speech.config") {
t.Errorf("speech.config-Nachricht ohne Path: %q", msg)
t.Errorf("speech.config message without Path: %q", msg)
}
if !strings.Contains(msg, edgeOutputFormat) {
t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg)
t.Errorf("speech.config message without output format %q: %q", edgeOutputFormat, msg)
}
}
// TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden
// ergänzt; der Token landet nicht in der URL-Debug-Ausgabe.
// TestEdgeRequestURL: query parameters (token, Sec-MS-GEC, version) are
// added; the token does not end up in the redacted URL output.
func TestEdgeRequestURL(t *testing.T) {
p := &Edge{Endpoint: "wss://edge.example.com/path"}
raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
@@ -112,20 +112,20 @@ func TestEdgeRequestURL(t *testing.T) {
"Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
} {
if !strings.Contains(raw, want) {
t.Errorf("requestURL = %q, will %q", raw, want)
t.Errorf("requestURL = %q, want %q", raw, want)
}
}
if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
t.Errorf("redactURL leakt den Token: %q", got)
t.Errorf("redactURL leaks the token: %q", got)
}
}
// TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster
// (innerhalb des Fensters stabil, über die Grenze anders) und exakte
// Referenzvektoren der edge-tts-DRM-Implementierung (v2).
// TestEdgeSecMSGEC: format "<16 hex>-<64 hex>" (upper case), 5-minute grid
// (stable within the window, different across the boundary) and exact
// reference vectors of the edge-tts DRM implementation (v2).
func TestEdgeSecMSGEC(t *testing.T) {
// Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der
// Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10).
// Reference vectors: (unix seconds, expected token), generated with the
// Python reference (edge-tts src/edge_tts/drm.py, as of 2026-10).
vectors := []struct {
ts int64
want string
@@ -141,36 +141,36 @@ func TestEdgeSecMSGEC(t *testing.T) {
}
}
// Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0).
// Window stability (base at window start, mod 300 == 0).
base := time.Unix(1700000400, 0).UTC()
token := edgeSecMSGEC(base)
if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab")
t.Error("Sec-MS-GEC differs within the 5-minute window")
}
if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht")
t.Error("Sec-MS-GEC does not change across the window boundary")
}
if len(token) != 16+1+64 || !strings.Contains(token, "-") {
t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token)
t.Errorf("token format = %q, want <16 hex>-<64 hex>", token)
}
}
// TestEdgeRequestID: 32 Hex-Zeichen, eindeutig.
// TestEdgeRequestID: 32 hex characters, unique.
func TestEdgeRequestID(t *testing.T) {
a, b := edgeRequestID(), edgeRequestID()
if len(a) != 32 {
t.Errorf("RequestID-Länge = %d, want 32", len(a))
t.Errorf("request ID length = %d, want 32", len(a))
}
if _, err := hex.DecodeString(a); err != nil {
t.Errorf("RequestID ist kein Hex: %v", err)
t.Errorf("request ID is not hex: %v", err)
}
if a == b {
t.Error("RequestID nicht eindeutig")
t.Error("request ID not unique")
}
}
// TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann
// Header (Path:audio), dann Payload.
// TestEdgeSplitBinary: binary frame: 2 bytes big-endian header length, then
// header (Path:audio), then payload.
func TestEdgeSplitBinary(t *testing.T) {
msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
header, payload, ok := splitEdgeBinary(msg)
@@ -178,15 +178,15 @@ func TestEdgeSplitBinary(t *testing.T) {
t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
}
if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
t.Error("übergroßer Header-Längenangabe soll abgelehnt werden")
t.Error("oversized header length should be rejected")
}
}
// fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein
// echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden
// Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit
// Path:audio, danach turn.end. audio ist die zu liefernde Payload;
// closing schließt die Verbindung vor turn.end (Fehlerpfad).
// fakeEdgeServer is a loopback fake of the Edge WSS protocol (no real
// network call; everything stays on 127.0.0.1). It checks the two client
// messages and answers per protocol: a binary audio frame with Path:audio,
// then turn.end. audio is the payload to deliver; closing closes the
// connection before turn.end (error path).
func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
t.Helper()
seen := &fakeEdgeSeen{}
@@ -208,21 +208,21 @@ func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server,
return
}
if _, cfg, err := conn.ReadMessage(); err != nil {
t.Errorf("speech.config lesen: %v", err)
t.Errorf("reading speech.config: %v", err)
} else {
seen.mu.Lock()
seen.config = string(cfg)
seen.mu.Unlock()
}
if _, req, err := conn.ReadMessage(); err != nil {
t.Errorf("ssml lesen: %v", err)
t.Errorf("reading ssml: %v", err)
} else {
seen.mu.Lock()
seen.ssml = string(req)
seen.mu.Unlock()
}
if closing {
return // Verbindung dichtmachen, ohne zu antworten
return // close the connection without answering
}
header := "X-RequestId:dummy\r\nPath:audio\r\n"
frame := make([]byte, 0, 2+len(header)+len(audio))
@@ -252,10 +252,9 @@ func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
return s.config, s.ssml
}
// TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den
// Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide
// Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe
// Task-03-Report für die Abgrenzung zum echten Endpoint.
// TestEdgeLoopbackFakeServer: complete protocol run against the loopback
// fake (tested fakeably: handshake parameters, both client messages,
// binary frame parsing, turn.end termination).
func TestEdgeLoopbackFakeServer(t *testing.T) {
wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
ts, seen := fakeEdgeServer(t, wantAudio, false)
@@ -263,9 +262,9 @@ func TestEdgeLoopbackFakeServer(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de")
data, err := p.Synthesize(context.Background(), "Hello & done", "de")
if err != nil {
t.Fatalf("Synthesize gegen Loopback-Fake: %v", err)
t.Fatalf("Synthesize against loopback fake: %v", err)
}
if string(data) != string(wantAudio) {
t.Errorf("Synthesize = %x, want %x", data, wantAudio)
@@ -275,47 +274,47 @@ func TestEdgeLoopbackFakeServer(t *testing.T) {
t.Errorf("speech.config = %q", configMsg)
}
if !strings.Contains(ssml, "Path:ssml") {
t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml)
t.Errorf("ssml request without Path:ssml: %q", ssml)
}
if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
t.Errorf("ssml ohne Default-Stimme de: %q", ssml)
t.Errorf("ssml without default voice de: %q", ssml)
}
if !strings.Contains(ssml, "Hallo &amp; fertig") {
t.Errorf("ssml ohne escapten Text: %q", ssml)
if !strings.Contains(ssml, "Hello &amp; done") {
t.Errorf("ssml without escaped text: %q", ssml)
}
}
// TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen
// ohne Audio → klarer „Protokoll geändert?"-Fehler.
func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) {
// TestEdgeLoopbackConnectionAbort: the server closes after the requests
// without audio → clear "protocol changed?" error.
func TestEdgeLoopbackConnectionAbort(t *testing.T) {
ts, _ := fakeEdgeServer(t, nil, true)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "Protokoll") {
t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err)
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), "protocol") {
t.Errorf("error = %v, want protocol-change hint", err)
}
}
// TestEdgeIntegrationEcht ist die als testing.Short überspringbare
// Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos,
// aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige
// manuelle Abnahme mit TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationEcht(t *testing.T) {
// TestEdgeIntegrationReal is the integration marker against the REAL
// Microsoft endpoint, skippable via testing.Short (free, but real network;
// standard test runs and -short skip it; one-off manual acceptance with
// TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationReal(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
t.Skip("skipped (-short)")
}
if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen")
t.Skip("skipped (real network); run with TALK_TEST_REAL_EDGE=1")
}
p := NewEdge(ChainConfig{})
data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de")
data, err := p.Synthesize(context.Background(), "Short speech test via Edge.", "de")
if err != nil {
t.Fatalf("Synthesize gegen echten Endpunkt: %v", err)
t.Fatalf("Synthesize against real endpoint: %v", err)
}
if len(data) == 0 {
t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes")
t.Fatal("Synthesize against real endpoint: 0 bytes")
}
t.Logf("Edge-Integration: %d Bytes Audio", len(data))
t.Logf("Edge integration: %d bytes of audio", len(data))
}
+46 -46
View File
@@ -4,35 +4,35 @@ import (
"encoding/binary"
)
// Edge-DRM: Sec-MS-GEC v2. Microsoft verlangt seit der Protokoll-Änderung
// (edge-tts-Referenz, Stand 2026) einen Token der Form
// „<16 Hex CS64>-<64 Hex SHA256-Variante>" über den 5-Minuten-gerundeten
// Windows-Filetime-Ticks + Trusted-Client-Token. Beide Digests sind
// absichtlich verschleierte Varianten bekannter Algorithmen — die
// Konstanten und der Kontrollfluss unten sind eine wörtliche Portierung
// der Referenz (edge-tts src/edge_tts/drm.py, MIT) und gegen deren
// Ausgaben tabellengeprüft (TestEdgeDRMReferenzvektoren). Ändert Microsoft
// das Schema erneut, liefert der Edge-Provider klare Fehler („Protokoll
// geändert?"), statt still zu versagen.
// Edge DRM: Sec-MS-GEC v2. Since the protocol change (edge-tts reference,
// as of 2026) Microsoft requires a token of the form
// "<16 hex CS64>-<64 hex SHA256 variant>" over the 5-minute-rounded
// Windows filetime ticks + trusted client token. Both digests are
// deliberately obfuscated variants of well-known algorithms; the constants
// and the control flow below are a literal port of the reference (edge-tts
// src/edge_tts/drm.py, MIT) and are table-checked against its outputs
// (TestEdgeSecMSGEC). If Microsoft changes the scheme again, the Edge
// provider returns clear errors ("protocol changed?") instead of failing
// silently.
const (
drmWinEpoch = 11644473600 // Sekunden zwischen 1601-01-01 und 1970-01-01
drmWinEpoch = 11644473600 // seconds between 1601-01-01 and 1970-01-01
)
// drmIV ist der Standard-SHA-256-Initialvektor.
// drmIV is the standard SHA-256 initial vector.
var drmIV = [8]uint32{
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
}
// drmMidState ist der Arbeitsregister-Stand der Referenz für den
// Mid-Block-Einstieg (Runde 14 des ersten Blocks).
// drmMidState is the reference's working-register state for the mid-block
// entry (round 14 of the first block).
var drmMidState = [8]uint32{
0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A,
0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2,
}
// drmSchedule sind vorberechnete Schedule-Anteile des ersten Blocks.
// drmSchedule holds precomputed schedule parts of the first block.
var drmSchedule = [14]uint32{
0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA,
0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, 0xF9E70AB6,
@@ -40,7 +40,7 @@ var drmSchedule = [14]uint32{
0xD258B060, 0x72766564,
}
// drmAdjustment ersetzt die Rundungskonstante K[14] im Mid-Block-Pfad.
// drmAdjustment replaces the round constant K[14] on the mid-block path.
const (
drmAdjustment uint32 = 0xC9DC06A7
drmSigma0Adj uint32 = 0x059C0B80
@@ -50,7 +50,7 @@ const (
drmCS64Adj1 uint32 = 0x0B1A0000
)
// drmK sind die Standard-SHA-256-Rundungskonstanten.
// drmK are the standard SHA-256 round constants.
var drmK = [64]uint32{
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
@@ -62,7 +62,7 @@ var drmK = [64]uint32{
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}
// drmCS64Coefficients: (Term-, Offset-Koeffizient) je Zeile.
// drmCS64Coefficients: (term coefficient, offset coefficient) per row.
var drmCS64Coefficients = [14][2]uint32{
{0xE82E91B1, 0xB32700D0}, {0x44DE24C6, 0x88DFF51D},
{0x3BEB65BC, 0x29514038}, {0xC89C5933, 0xB328CBDF},
@@ -73,23 +73,23 @@ var drmCS64Coefficients = [14][2]uint32{
{0x35FA38C6, 0xBB04A2C1}, {0xCB49E3C6, 0xC5626167},
}
// drmSigma0 ist die (verschleiert notierte) SHA-256-σ0-Mischfunktion.
// drmSigma0 is the (obfuscated-notation) SHA-256 σ0 mixing function.
func drmSigma0(v uint32) uint32 {
return v>>7 ^ v<<25 ^ v>>18 ^ v<<14 ^ v>>3
}
// drmSigma1 ist die SHA-256-σ1-Mischfunktion.
// drmSigma1 is the SHA-256 σ1 mixing function.
func drmSigma1(v uint32) uint32 {
return v>>17 ^ v<<15 ^ v>>19 ^ v<<13 ^ v>>10
}
// drmRol16 rotiert 32 Bit um 16.
// drmRol16 rotates 32 bits by 16.
func drmRol16(v uint32) uint32 { return v<<16 | v>>16 }
// drmRotR rotiert 32 Bit rechts um n.
// drmRotR rotates 32 bits right by n.
func drmRotR(v uint32, n uint) uint32 { return v>>n | v<<(32-n) }
// drmExpand erweitert die Message-Schedule ab from auf 64 Wörter.
// drmExpand extends the message schedule from index from to 64 words.
func drmExpand(words []uint32, from int) []uint32 {
for i := from; i < 64; i++ {
x, y := words[i-2], words[i-15]
@@ -98,9 +98,9 @@ func drmExpand(words []uint32, from int) []uint32 {
return words
}
// drmRounds führt die SHA-256-Runden aus — wörtlich nach Referenz,
// inklusive des Mid-Block-Einstiegs (firstRound 14 mit ersetzter
// Rundungskonstante) und der 8er-Rollen-Rotation der Register.
// drmRounds executes the SHA-256 rounds, literally following the
// reference, including the mid-block entry (firstRound 14 with the replaced
// round constant) and the 8-way role rotation of the registers.
func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h := state[0], state[1], state[2], state[3], state[4], state[5], state[6], state[7]
midBlock := firstRound == 14
@@ -120,35 +120,35 @@ func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
a, b, c, d, e, f, g, h = t1+t2, a, b, c, d+t1, e, f, g
}
for i := aligned; i < 64; i += 8 {
// Runde i
// round i
t1 := h + (drmRotR(e, 6) ^ drmRotR(e, 11) ^ drmRotR(e, 25)) + (g ^ (e & (f ^ g))) + k(i) + words[i]
d += t1
h = t1 + (drmRotR(a, 2) ^ drmRotR(a, 13) ^ drmRotR(a, 22)) + ((a & b) | (c & (a | b)))
// Runde i+1 (Rollen rotiert)
// round i+1 (roles rotated)
t1 = g + (drmRotR(d, 6) ^ drmRotR(d, 11) ^ drmRotR(d, 25)) + (f ^ (d & (e ^ f))) + k(i+1) + words[i+1]
c += t1
g = t1 + (drmRotR(h, 2) ^ drmRotR(h, 13) ^ drmRotR(h, 22)) + ((h & a) | (b & (h | a)))
// Runde i+2
// round i+2
t1 = f + (drmRotR(c, 6) ^ drmRotR(c, 11) ^ drmRotR(c, 25)) + (e ^ (c & (d ^ e))) + k(i+2) + words[i+2]
b += t1
f = t1 + (drmRotR(g, 2) ^ drmRotR(g, 13) ^ drmRotR(g, 22)) + ((g & h) | (a & (g | h)))
// Runde i+3
// round i+3
t1 = e + (drmRotR(b, 6) ^ drmRotR(b, 11) ^ drmRotR(b, 25)) + (d ^ (b & (c ^ d))) + k(i+3) + words[i+3]
a += t1
e = t1 + (drmRotR(f, 2) ^ drmRotR(f, 13) ^ drmRotR(f, 22)) + ((f & g) | (h & (f | g)))
// Runde i+4
// round i+4
t1 = d + (drmRotR(a, 6) ^ drmRotR(a, 11) ^ drmRotR(a, 25)) + (c ^ (a & (b ^ c))) + k(i+4) + words[i+4]
h += t1
d = t1 + (drmRotR(e, 2) ^ drmRotR(e, 13) ^ drmRotR(e, 22)) + ((e & f) | (g & (e | f)))
// Runde i+5
// round i+5
t1 = c + (drmRotR(h, 6) ^ drmRotR(h, 11) ^ drmRotR(h, 25)) + (b ^ (h & (a ^ b))) + k(i+5) + words[i+5]
g += t1
c = t1 + (drmRotR(d, 2) ^ drmRotR(d, 13) ^ drmRotR(d, 22)) + ((d & e) | (f & (d | e)))
// Runde i+6
// round i+6
t1 = b + (drmRotR(g, 6) ^ drmRotR(g, 11) ^ drmRotR(g, 25)) + (a ^ (g & (h ^ a))) + k(i+6) + words[i+6]
f += t1
b = t1 + (drmRotR(c, 2) ^ drmRotR(c, 13) ^ drmRotR(c, 22)) + ((c & d) | (e & (c | d)))
// Runde i+7
// round i+7
t1 = a + (drmRotR(f, 6) ^ drmRotR(f, 11) ^ drmRotR(f, 25)) + (h ^ (f & (g ^ h))) + k(i+7) + words[i+7]
e += t1
a = t1 + (drmRotR(b, 2) ^ drmRotR(b, 13) ^ drmRotR(b, 22)) + ((b & c) | (d & (b | c)))
@@ -156,16 +156,16 @@ func drmRounds(state [8]uint32, words []uint32, firstRound int) [8]uint32 {
return [8]uint32{a, b, c, d, e, f, g, h}
}
// drmSHA256Init berechnet den Zustand nach dem (verschleiert notierten)
// ersten Block: 14 Nullwörter + die ersten 7 Bytes von data als
// 24-Bit-/32-Bit-Big-Endian-Wörter, Schedule mit Korrekturen, Runden ab 14.
// drmSHA256Init computes the state after the (obfuscated-notation) first
// block: 14 zero words + the first 7 bytes of data as 24-bit/32-bit
// big-endian words, schedule with corrections, rounds from 14 onwards.
func drmSHA256Init(data []byte) [8]uint32 {
words := make([]uint32, 30, 64)
words[14] = uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
words[15] = binary.BigEndian.Uint32(data[3:7])
for i := 16; i < 30; i++ {
v := drmSchedule[i-16]
// σ1-Anteil von W[i-2] (Index 16 liest W[14], dazu die Korrektur).
// σ1 part of W[i-2] (index 16 reads W[14], plus the correction).
s1 := drmSigma1(words[i-2])
if i == 16 {
s1 ^= drmSigma1Adj
@@ -174,7 +174,7 @@ func drmSHA256Init(data []byte) [8]uint32 {
if i-7 >= 14 {
v += words[i-7]
}
if i-15 >= 14 { // nur i == 29, liest W[14], dazu die Korrektur
if i-15 >= 14 { // only i == 29, reads W[14], plus the correction
s0 := drmSigma0(words[i-15])
if i == 29 {
s0 ^= drmSigma0Adj
@@ -193,9 +193,9 @@ func drmSHA256Init(data []byte) [8]uint32 {
return out
}
// drmSHA256Digest ist die verschleierte SHA-256-Variante der Referenz:
// Block 1 steckt in drmSHA256Init (erste 7 Bytes), danach data[7:] mit
// Referenz-Längenfeld (57+len(data)) und Standard-Runden.
// drmSHA256Digest is the reference's obfuscated SHA-256 variant: block 1
// lives in drmSHA256Init (first 7 bytes), followed by data[7:] with the
// reference length field (57+len(data)) and standard rounds.
func drmSHA256Digest(data []byte) [8]uint32 {
length := 57 + len(data)
padded := append([]byte(nil), data[7:]...)
@@ -224,8 +224,8 @@ func drmSHA256Digest(data []byte) [8]uint32 {
return state
}
// drmWordsLE4Even zerlegt data in little-endian u32-Wörter (auf 4 Bytes
// null-aufgefüllt, Wortzahl gerade).
// drmWordsLE4Even splits data into little-endian u32 words (zero-padded to
// 4 bytes, even word count).
func drmWordsLE4Even(data []byte) []uint32 {
padded := make([]byte, len(data))
copy(padded, data)
@@ -242,8 +242,8 @@ func drmWordsLE4Even(data []byte) []uint32 {
return words
}
// drmCS64 berechnet die beiden CS64-Prüfsummen-Wörter (wörtliche
// Portierung; Schlüssel aus den ersten 8 Digest-Bytes little-endian).
// drmCS64 computes the two CS64 checksum words (literal port; keys from
// the first 8 digest bytes, little-endian).
func drmCS64(data []byte, digest [8]uint32) (a, b uint32) {
var digestBytes [32]byte
for i, w := range digest {
+33 -33
View File
@@ -8,51 +8,50 @@ import (
"strings"
)
// ElevenLabs-Konstanten (öffentlicher Vertrag: Env-Namen sind Teil der
// Fleet-Doku, Endpunkt-Default Teil der Konfigurierbarkeit).
// ElevenLabs constants (public contract: the env names are part of the
// fleet documentation, the endpoint default part of the configurability).
const (
// DefaultEndpointElevenLabs ist der produktive API-Endpunkt.
// DefaultEndpointElevenLabs is the production API endpoint.
DefaultEndpointElevenLabs = "https://api.elevenlabs.io"
// EnvElevenLabsKey liefert den API-Key (Spec: Keys ausschließlich Env,
// nie config.toml).
// EnvElevenLabsKey provides the API key (spec: keys come exclusively
// from the environment, never from config.toml).
EnvElevenLabsKey = "TALK_ELEVENLABS_KEY"
// EnvElevenLabsEndpoint überschreibt den Endpunkt (Tests/Firmenserver;
// schlägt die config.toml, siehe voices.go/chain.go-Doku).
// EnvElevenLabsEndpoint overrides the endpoint (tests/corporate
// servers; beats config.toml, see the voices.go/chain.go docs).
EnvElevenLabsEndpoint = "TALK_ELEVENLABS_ENDPOINT"
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2, Task-Brief
elevenLabsModel = "eleven_multilingual_v2" // multilingual v2
)
// ElevenLabs ist die Premium-Stufe der Kette. Synthesize führt POST
// <endpoint>/v1/text-to-speech/<voiceID> aus — Header `xi-api-key`, Body
// ElevenLabs is the premium tier of the chain. Synthesize performs POST
// <endpoint>/v1/text-to-speech/<voiceID> with header `xi-api-key` and body
// {text, model_id: "eleven_multilingual_v2", voice_settings:
// {stability: 0.5, similarity_boost: 0.75}} — und liefert die Antwort-Bytes
// unverändert zurück. Rückgabeformat: MP3 (dokumentierter Vertrag des
// audio-Pakets).
// {stability: 0.5, similarity_boost: 0.75}} and returns the response bytes
// unchanged. Return format: MP3 (documented contract of the audio package).
type ElevenLabs struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer wird beim Bau durch den
// Default ersetzt.
// Endpoint is the base URL (without path); empty is replaced by the
// default at construction.
Endpoint string
// APIKey ist der ElevenLabs-Key (aus TALK_ELEVENLABS_KEY).
// APIKey is the ElevenLabs key (from TALK_ELEVENLABS_KEY).
APIKey string
// Voices ist das config-Mapping voice.elevenlabs.<lang> = id; leer =
// neutrale Repo-Defaults (siehe voices.go).
// Voices is the config mapping voice.elevenlabs.<lang> = id; empty =
// neutral repo defaults (see voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout
// Client is the HTTP client; nil = shared client with timeout
// (cloudTimeout).
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewElevenLabs baut den Provider aus der Ketten-Konfiguration: Endpunkt
// aus TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > Default;
// Key ausschließlich aus TALK_ELEVENLABS_KEY.
// NewElevenLabs builds the provider from the chain configuration: endpoint
// from TALK_ELEVENLABS_ENDPOINT > [providers.elevenlabs].endpoint > default;
// key exclusively from TALK_ELEVENLABS_KEY.
func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
return &ElevenLabs{
Endpoint: endpointFrom(cfg, "elevenlabs", EnvElevenLabsEndpoint, DefaultEndpointElevenLabs),
@@ -61,20 +60,21 @@ func NewElevenLabs(cfg ChainConfig) *ElevenLabs {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*ElevenLabs) Name() string { return "elevenlabs" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *ElevenLabs) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (bewusst ohne Netz-Call — Available muss billig bleiben; ob der Key
// gültig ist, stellt Synthesize fest und die Kette fällt weiter).
// Available implements Provider: key and endpoint must be set (deliberately
// without a network call; Available must stay cheap. Whether the key is
// valid is determined by Synthesize, and the chain falls back).
func (p *ElevenLabs) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// elevenLabsRequest ist der Antwort-Body (Task-Brief, wörtliche Werte).
// elevenLabsRequest is the request body (literal values from the API
// contract).
type elevenLabsRequest struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
@@ -86,14 +86,14 @@ type elevenLabsVoiceSettings struct {
SimilarityBoost float64 `json:"similarity_boost"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("elevenlabs: leerer Text")
return nil, fmt.Errorf("elevenlabs: empty text")
}
voice := resolveVoice("elevenlabs", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("elevenlabs: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("elevenlabs: no voice configured for language %q", lang)
}
body, err := json.Marshal(elevenLabsRequest{
Text: text,
@@ -101,7 +101,7 @@ func (p *ElevenLabs) Synthesize(ctx context.Context, text, lang string) ([]byte,
VoiceSettings: elevenLabsVoiceSettings{Stability: 0.5, SimilarityBoost: 0.75},
})
if err != nil {
return nil, fmt.Errorf("elevenlabs: Anfrage-Body bauen: %w", err)
return nil, fmt.Errorf("elevenlabs: building request body: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/text-to-speech/" + voice
return postForBytes(ctx, p.Client, "elevenlabs", url,
+47 -47
View File
@@ -11,7 +11,7 @@ import (
"testing"
)
// fakeTTServation zeichnet einen Request am Fake-Server auf.
// recordedRequest records a request seen by the fake server.
type recordedRequest struct {
method string
path string
@@ -19,8 +19,8 @@ type recordedRequest struct {
body []byte
}
// fakeTTSServer ist der httptest-Fake eines Cloud-Providers: antwortet mit
// status + body und zeichnet den (einzigen) gesehenen Request auf.
// fakeTTSServer is the httptest fake of a cloud provider: responds with
// status + body and records the (single) request it saw.
func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
@@ -28,7 +28,7 @@ func fakeTTSServer(t *testing.T, status int, body []byte) (*httptest.Server, *re
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body2, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("Fake-Server: Body lesen: %v", err)
t.Errorf("fake server: reading body: %v", err)
}
mu.Lock()
rec.method, rec.path, rec.header, rec.body = r.Method, r.URL.Path, r.Header.Clone(), body2
@@ -44,14 +44,14 @@ func envMap(kv map[string]string) func(string) string {
return func(k string) string { return kv[k] }
}
// fakeMP3 ist eine beliebige Byte-Folge — die Provider geben Antwort-Bytes
// unverändert zurück, der audio-Sink dekodiert (eigene Tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-von-elf")
// fakeMP3 is an arbitrary byte sequence; the providers return response
// bytes unchanged, the audio sink decodes (its own tests).
var fakeMP3 = []byte("\xff\xfb\x90\x00" + "audio-bytes-from-eleven")
// TestElevenLabsSynthese200: 200 mit Bytes → Bytes kommen unverändert
// zurück; Methode, Pfad (inkl. Voice-ID), Header (xi-api-key) und der
// JSON-Body (model_id, voice_settings) entsprechen dem Task-Brief.
func TestElevenLabsSynthese200(t *testing.T) {
// TestElevenLabsSynthesize200: 200 with bytes → bytes come back unchanged;
// method, path (incl. voice ID), header (xi-api-key) and the JSON body
// (model_id, voice_settings) match the API contract.
func TestElevenLabsSynthesize200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{
Getenv: envMap(map[string]string{
@@ -60,18 +60,18 @@ func TestElevenLabsSynthese200(t *testing.T) {
}),
})
data, err := p.Synthesize(context.Background(), "Hallo Welt", "de")
data, err := p.Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes %q", data, fakeMP3)
t.Errorf("Synthesize = %q, want fake bytes %q", data, fakeMP3)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
t.Errorf("method = %s, want POST", rec.method)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want %q (neutrales Repo-Default „Rachel“)", rec.path, want)
t.Errorf("path = %q, want %q (neutral repo default \"Rachel\")", rec.path, want)
}
if got := rec.header.Get("xi-api-key"); got != "testkey" {
t.Errorf("xi-api-key = %q, want %q", got, "testkey")
@@ -86,9 +86,9 @@ func TestElevenLabsSynthese200(t *testing.T) {
} `json:"voice_settings"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Antwort-Body ist kein JSON: %v (%s)", err, rec.body)
t.Fatalf("request body is not JSON: %v (%s)", err, rec.body)
}
if body.Text != "Hallo Welt" {
if body.Text != "Hello world" {
t.Errorf("text = %q", body.Text)
}
if body.ModelID != "eleven_multilingual_v2" {
@@ -99,9 +99,9 @@ func TestElevenLabsSynthese200(t *testing.T) {
}
}
// TestElevenLabsHTTPFehler: 500 und 401 sind klare Fehler, die Status und
// einen Body-Ausschnitt nennen (die Kette fällt daran weiter).
func TestElevenLabsHTTPFehler(t *testing.T) {
// TestElevenLabsHTTPError: 500 and 401 are clear errors naming the status
// and a body snippet (the chain falls back on them).
func TestElevenLabsHTTPError(t *testing.T) {
cases := []struct {
status int
body string
@@ -117,32 +117,32 @@ func TestElevenLabsHTTPFehler(t *testing.T) {
EnvElevenLabsKey: "testkey",
EnvElevenLabsEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %s", err, tc.want)
t.Fatalf("Synthesize error = %v, want HTTP %s", err, tc.want)
}
if !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestElevenLabsAvailable: ohne Key nicht verfügbar, mit Key verfügbar —
// ohne jeden Netz-Call.
// TestElevenLabsAvailable: not available without a key, available with
// one; without any network call.
func TestElevenLabsAvailable(t *testing.T) {
if NewElevenLabs(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
t.Error("Available without key = true, want false")
}
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{EnvElevenLabsKey: "k"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
t.Error("Available with key = false, want true")
}
}
// TestElevenLabsVoiceRangfolge: --voice-Pin schlägt config-Mapping, Mapping
// schlägt Repo-Default (beobachtbar am Request-Pfad).
func TestElevenLabsVoiceRangfolge(t *testing.T) {
// TestElevenLabsVoicePrecedence: the --voice pin beats the config mapping,
// the mapping beats the repo default (observable in the request path).
func TestElevenLabsVoicePrecedence(t *testing.T) {
mk := func(t *testing.T, voices map[string]string, pin string) (*ElevenLabs, *recordedRequest) {
t.Helper()
ts, rec := fakeTTSServer(t, 200, fakeMP3)
@@ -159,46 +159,46 @@ func TestElevenLabsVoiceRangfolge(t *testing.T) {
return p, rec
}
t.Run("config-Mapping schlägt Default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Run("config mapping beats default", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "")
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/AusConfig"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
if want := "/v1/text-to-speech/FromConfig"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("Pin (--voice) schlägt config-Mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "AusConfig"}, "Gepinnt")
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
t.Run("pin (--voice) beats config mapping", func(t *testing.T) {
p, rec := mk(t, map[string]string{"de": "FromConfig"}, "Pinned")
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/Gepinnt"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
if want := "/v1/text-to-speech/Pinned"; rec.path != want {
t.Errorf("path = %q, want %q", rec.path, want)
}
})
t.Run("unbekannte Sprache fällt auf en-Default", func(t *testing.T) {
t.Run("unknown language falls back to en default", func(t *testing.T) {
p, rec := mk(t, nil, "")
if _, err := p.Synthesize(context.Background(), "Hallo", "fr"); err != nil {
if _, err := p.Synthesize(context.Background(), "Hello", "fr"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
if want := "/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM"; rec.path != want {
t.Errorf("Pfad = %q, want en-Default %q", rec.path, want)
t.Errorf("path = %q, want en default %q", rec.path, want)
}
})
}
// TestElevenLabsLeererText ist ein klarer Fehler ohne Request.
func TestElevenLabsLeererText(t *testing.T) {
// TestElevenLabsEmptyText is a clear error without a request.
func TestElevenLabsEmptyText(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewElevenLabs(ChainConfig{Getenv: envMap(map[string]string{
EnvElevenLabsKey: "k",
EnvElevenLabsEndpoint: ts.URL,
})})
if _, err := p.Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
t.Error("Synthesize(\"\") should fail")
}
if rec.method != "" {
t.Error("Bei leerem Text darf kein Request gehen")
t.Error("no request must be sent for empty text")
}
}
+19 -19
View File
@@ -9,30 +9,30 @@ import (
"time"
)
// cloudTimeout ist das Budget für einen einzigen Synthese-Call eines
// Cloud-Providers (Aufbau + Anfrage + vollständige Antwort). Ansagen sind
// kurz; nach Ablauf greift der Fallback auf die nächste Ketten-Stufe.
// cloudTimeout is the budget for a single synthesis call of a cloud
// provider (connect + request + complete response). Announcements are
// short; once it expires the chain falls back to the next tier.
const cloudTimeout = 45 * time.Second
// cloudClient ist der geteilte HTTP-Client der REST-Provider (mit Timeout,
// im Gegensatz zu http.DefaultClient). Bei Ausbleiben einer Antwort greift
// so der Ketten-Fallback statt einer Endlos-Blockade.
// cloudClient is the shared HTTP client of the REST providers (with a
// timeout, unlike http.DefaultClient). If a response never arrives, the
// chain falls back instead of blocking forever.
var cloudClient = &http.Client{Timeout: cloudTimeout}
// maxErrBody begrenzt, wie viel einer Fehler-Antwort in die Fehlermeldung
// übernommen wird (Diagnose ja, Spam nein).
// maxErrBody limits how much of an error response is copied into the error
// message (diagnosis yes, spam no).
const maxErrBody = 200
// postForBytes macht den gemeinsamen Kern der REST-Provider: POST von JSON
// an url mit Headern, erwartet 2xx und liefert die rohen Antwort-Bytes
// (Audio). Alles andere — non-2xx, Netz-Fehler, Timeouts, leere Antworten —
// ist ein klarer Fehler, an dem die Kette in die nächste Stufe fällt.
// postForBytes is the shared core of the REST providers: POST JSON to url
// with headers, expect 2xx and return the raw response bytes (audio).
// Everything else (non-2xx, network errors, timeouts, empty responses) is a
// clear error on which the chain falls back to the next tier.
func postForBytes(ctx context.Context, client *http.Client, providerName, url string, headers map[string]string, body []byte) ([]byte, error) {
if client == nil {
client = cloudClient
}
// Eigenes Zeit-Budget setzen, falls der Aufrufer keins mitgibt — die
// Kette soll auch ohne fremden Deadline-Kontext weiterfallen können.
// Apply our own time budget if the caller does not bring one; the chain
// must be able to fall through even without an external deadline.
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, cloudTimeout)
@@ -41,7 +41,7 @@ func postForBytes(ctx context.Context, client *http.Client, providerName, url st
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s bauen: %w", providerName, url, err)
return nil, fmt.Errorf("%s: building request to %s: %w", providerName, url, err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
@@ -50,21 +50,21 @@ func postForBytes(ctx context.Context, client *http.Client, providerName, url st
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: Anfrage an %s fehlgeschlagen (Netz/Timeout?): %w", providerName, url, err)
return nil, fmt.Errorf("%s: request to %s failed (network/timeout?): %w", providerName, url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
return nil, fmt.Errorf("%s: HTTP %d von %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
return nil, fmt.Errorf("%s: HTTP %d from %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%s: Antwort von %s lesen: %w", providerName, url, err)
return nil, fmt.Errorf("%s: reading response from %s: %w", providerName, url, err)
}
if len(data) == 0 {
return nil, fmt.Errorf("%s: leere Antwort von %s (0 Bytes Audio)", providerName, url)
return nil, fmt.Errorf("%s: empty response from %s (0 bytes of audio)", providerName, url)
}
return data, nil
}
+29 -28
View File
@@ -8,47 +8,48 @@ import (
"strings"
)
// OpenAI-TTS-Konstanten (öffentlicher Vertrag wie bei ElevenLabs).
// OpenAI TTS constants (public contract as with ElevenLabs).
const (
// DefaultEndpointOpenAI ist der produktive API-Endpunkt.
// DefaultEndpointOpenAI is the production API endpoint.
DefaultEndpointOpenAI = "https://api.openai.com"
// DefaultModelOpenAI: Entscheidungsnotiz aus dem Task-Brief — der
// Modell-String ist konfigurierbar ([providers.openai] model), Default
// "tts-1"; "gpt-4o-mini-tts" ist ein gültiger Alternativwert.
// DefaultModelOpenAI: decision note. The model string is configurable
// ([providers.openai] model), default "tts-1"; "gpt-4o-mini-tts" is a
// valid alternative.
DefaultModelOpenAI = "tts-1"
EnvOpenAIKey = "TALK_OPENAI_KEY"
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
)
// OpenAI ist die zweite Stufe der Kette (für Nutzer mit vorhandenem
// OpenAI-Key). Synthesize führt POST <endpoint>/v1/audio/speech aus —
// Bearer-Key, Body {model, voice, input, response_format: "mp3"} — und
// liefert die Antwort-Bytes unverändert zurück. Rückgabeformat: MP3.
// OpenAI is the second tier of the chain (for users with an existing OpenAI
// key). Synthesize performs POST <endpoint>/v1/audio/speech with a bearer
// key and body {model, voice, input, response_format: "mp3"} and returns
// the response bytes unchanged. Return format: MP3.
type OpenAI struct {
// Endpoint ist die Basis-URL (ohne Pfad); leer = Default beim Bau.
// Endpoint is the base URL (without path); empty = default at
// construction.
Endpoint string
// APIKey ist der OpenAI-Key (aus TALK_OPENAI_KEY).
// APIKey is the OpenAI key (from TALK_OPENAI_KEY).
APIKey string
// Model ist der TTS-Modell-String; leer = DefaultModelOpenAI.
// Model is the TTS model string; empty = DefaultModelOpenAI.
Model string
// Voices ist das config-Mapping voice.openai.<lang> = id; leer =
// neutrale Repo-Defaults (alloy, siehe voices.go).
// Voices is the config mapping voice.openai.<lang> = id; empty =
// neutral repo defaults (alloy, see voices.go).
Voices map[string]string
// Client ist der HTTP-Client; nil = geteilter Client mit Timeout.
// Client is the HTTP client; nil = shared client with timeout.
Client *http.Client
pinned string // --voice-Pin (VoicePinner)
pinned string // --voice pin (VoicePinner)
}
// NewOpenAI baut den Provider aus der Ketten-Konfiguration: Endpunkt aus
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > Default; Modell aus
// [providers.openai].model; Key ausschließlich aus TALK_OPENAI_KEY.
// NewOpenAI builds the provider from the chain configuration: endpoint from
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > default; model from
// [providers.openai].model; key exclusively from TALK_OPENAI_KEY.
func NewOpenAI(cfg ChainConfig) *OpenAI {
return &OpenAI{
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
@@ -58,19 +59,19 @@ func NewOpenAI(cfg ChainConfig) *OpenAI {
}
}
// Name implementiert Provider.
// Name implements Provider.
func (*OpenAI) Name() string { return "openai" }
// PinVoice implementiert VoicePinner (CLI-Flag --voice).
// PinVoice implements VoicePinner (CLI flag --voice).
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
// Available implementiert Provider: Key und Endpunkt müssen gesetzt sein
// (ohne Netz-Call, siehe ElevenLabs.Available).
// Available implements Provider: key and endpoint must be set (without a
// network call, see ElevenLabs.Available).
func (p *OpenAI) Available(ctx context.Context) bool {
return p.APIKey != "" && p.Endpoint != ""
}
// openAIRequest ist der Anfrage-Body.
// openAIRequest is the request body.
type openAIRequest struct {
Model string `json:"model"`
Voice string `json:"voice"`
@@ -78,14 +79,14 @@ type openAIRequest struct {
ResponseFormat string `json:"response_format"`
}
// Synthesize implementiert Provider. Rückgabeformat: MP3.
// Synthesize implements Provider. Return format: MP3.
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("openai: leerer Text")
return nil, fmt.Errorf("openai: empty text")
}
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
if voice == "" {
return nil, fmt.Errorf("openai: keine Stimme für Sprache %q konfiguriert", lang)
return nil, fmt.Errorf("openai: no voice configured for language %q", lang)
}
model := p.Model
if model == "" {
@@ -98,7 +99,7 @@ func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, err
ResponseFormat: "mp3",
})
if err != nil {
return nil, fmt.Errorf("openai: Anfrage-Body bauen: %w", err)
return nil, fmt.Errorf("openai: building request body: %w", err)
}
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
return postForBytes(ctx, p.Client, "openai", url,
+29 -29
View File
@@ -9,10 +9,10 @@ import (
"testing"
)
// TestOpenAISynthese200: 200 mit Bytes → Bytes unverändert; POST
// /v1/audio/speech, Bearer-Key, Body mit model/voice/input/response_format
// laut Task-Brief (Default-Modell tts-1, Default-Stimme alloy).
func TestOpenAISynthese200(t *testing.T) {
// TestOpenAISynthesize200: 200 with bytes → bytes unchanged; POST
// /v1/audio/speech, bearer key, body with model/voice/input/response_format
// per the API contract (default model tts-1, default voice alloy).
func TestOpenAISynthesize200(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -21,18 +21,18 @@ func TestOpenAISynthese200(t *testing.T) {
}),
})
data, err := p.Synthesize(context.Background(), "Build fertig", "en")
data, err := p.Synthesize(context.Background(), "Build finished", "en")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(fakeMP3) {
t.Errorf("Synthesize = %q, want Fake-Bytes", data)
t.Errorf("Synthesize = %q, want fake bytes", data)
}
if rec.method != http.MethodPost {
t.Errorf("Methode = %s, want POST", rec.method)
t.Errorf("method = %s, want POST", rec.method)
}
if want := "/v1/audio/speech"; rec.path != want {
t.Errorf("Pfad = %q, want %q", rec.path, want)
t.Errorf("path = %q, want %q", rec.path, want)
}
if got := rec.header.Get("Authorization"); got != "Bearer sk-test" {
t.Errorf("Authorization = %q, want Bearer sk-test", got)
@@ -45,15 +45,15 @@ func TestOpenAISynthese200(t *testing.T) {
ResponseFormat string `json:"response_format"`
}
if err := json.Unmarshal(rec.body, &body); err != nil {
t.Fatalf("Body ist kein JSON: %v (%s)", err, rec.body)
t.Fatalf("body is not JSON: %v (%s)", err, rec.body)
}
if body.Model != "tts-1" {
t.Errorf("model = %q, want Default tts-1", body.Model)
t.Errorf("model = %q, want default tts-1", body.Model)
}
if body.Voice != "alloy" {
t.Errorf("voice = %q, want Default alloy", body.Voice)
t.Errorf("voice = %q, want default alloy", body.Voice)
}
if body.Input != "Build fertig" {
if body.Input != "Build finished" {
t.Errorf("input = %q", body.Input)
}
if body.ResponseFormat != "mp3" {
@@ -61,8 +61,8 @@ func TestOpenAISynthese200(t *testing.T) {
}
}
// TestOpenAIHTTPFehler: 500 und 401 sind klare Fehler mit Status.
func TestOpenAIHTTPFehler(t *testing.T) {
// TestOpenAIHTTPError: 500 and 401 are clear errors with the status.
func TestOpenAIHTTPError(t *testing.T) {
for _, status := range []int{500, 401} {
t.Run(http.StatusText(status), func(t *testing.T) {
ts, _ := fakeTTSServer(t, status, []byte("boom"))
@@ -70,21 +70,21 @@ func TestOpenAIHTTPFehler(t *testing.T) {
EnvOpenAIKey: "sk-test",
EnvOpenAIEndpoint: ts.URL,
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
_, err := p.Synthesize(context.Background(), "Hello", "de")
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("%d", status)) {
t.Fatalf("Synthesize-Fehler = %v, will HTTP %d", err, status)
t.Fatalf("Synthesize error = %v, want HTTP %d", err, status)
}
if !strings.Contains(err.Error(), "openai") {
t.Errorf("Fehler soll den Provider nennen: %v", err)
t.Errorf("error should name the provider: %v", err)
}
})
}
}
// TestOpenAIModelKonfigurierbar: [providers.openai].model ändert den
// Modell-String im Body (Entscheidungsnotiz: Default tts-1, Alternativen
// wie gpt-4o-mini-tts erlaubt).
func TestOpenAIModelKonfigurierbar(t *testing.T) {
// TestOpenAIModelConfigurable: [providers.openai].model changes the model
// string in the body (decision note: default tts-1, alternatives such as
// gpt-4o-mini-tts allowed).
func TestOpenAIModelConfigurable(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -93,7 +93,7 @@ func TestOpenAIModelKonfigurierbar(t *testing.T) {
}),
Model: "gpt-4o-mini-tts",
})
if _, err := p.Synthesize(context.Background(), "Hallo", "de"); err != nil {
if _, err := p.Synthesize(context.Background(), "Hello", "de"); err != nil {
t.Fatalf("Synthesize: %v", err)
}
var body struct {
@@ -107,20 +107,20 @@ func TestOpenAIModelKonfigurierbar(t *testing.T) {
}
}
// TestOpenAIAvailable: Key entscheidet — ohne Netz-Call.
// TestOpenAIAvailable: the key decides; without a network call.
func TestOpenAIAvailable(t *testing.T) {
if NewOpenAI(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available ohne Key = true, want false")
t.Error("Available without key = true, want false")
}
p := NewOpenAI(ChainConfig{Getenv: envMap(map[string]string{EnvOpenAIKey: "sk"})})
if !p.Available(context.Background()) {
t.Error("Available mit Key = false, want true")
t.Error("Available with key = false, want true")
}
}
// TestOpenAIVoiceRangfolge: Pin schlägt Mapping schlägt Default.
func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Run("Mapping schlägt Default", func(t *testing.T) {
// TestOpenAIVoicePrecedence: pin beats mapping beats default.
func TestOpenAIVoicePrecedence(t *testing.T) {
t.Run("mapping beats default", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
@@ -140,7 +140,7 @@ func TestOpenAIVoiceRangfolge(t *testing.T) {
t.Errorf("voice = %q, want nova (config)", body.Voice)
}
})
t.Run("Pin schlägt Mapping", func(t *testing.T) {
t.Run("pin beats mapping", func(t *testing.T) {
ts, rec := fakeTTSServer(t, 200, fakeMP3)
p := NewOpenAI(ChainConfig{
Getenv: envMap(map[string]string{
+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 {
+65 -65
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
// fakeBin schreibt ein ausführbares Shell-Skript namens name in dir, das
// body ausführt. Damit werden die externen Prozesse des Providers an der
// echten Prozessgrenze gefaked (PATH-Manipulation, kein Mock-Framework).
// fakeBin writes an executable shell script called name into dir that runs
// body. This fakes the provider's external processes at the real process
// boundary (PATH manipulation, no mock framework).
func fakeBin(t *testing.T, dir, name, body string) {
t.Helper()
script := "#!/bin/sh\n" + body + "\n"
@@ -22,8 +22,8 @@ func fakeBin(t *testing.T, dir, name, body string) {
}
}
// emptyPATH setzt PATH auf ein Verzeichnis ohne einzige Binärdatei —
// kein espeak, kein spd-say.
// emptyPATH sets PATH to a directory without a single binary: no espeak,
// no spd-say.
func emptyPATH(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -31,15 +31,15 @@ func emptyPATH(t *testing.T) string {
return dir
}
// prependPATH stellt dir vor den bestehenden PATH: Binaries darin
// überschatten System-Binaries gleichen Namens (fake espeak vor einem
// echten), während Hilfsprogramme wie cat weiterhin erreichbar bleiben.
// prependPATH puts dir in front of the existing PATH: binaries in it shadow
// system binaries of the same name (fake espeak before a real one) while
// helpers such as cat stay reachable.
func prependPATH(t *testing.T, dir string) {
t.Helper()
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestOSBuiltinName und TestNewRegistry pinnen die Registry-Sicht.
// TestOSBuiltinName and TestNewRegistry pin the registry view.
func TestOSBuiltinName(t *testing.T) {
if got := (OSBuiltin{}).Name(); got != "osbuiltin" {
t.Errorf("Name() = %q, want %q", got, "osbuiltin")
@@ -56,7 +56,7 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
// Task 03: die Cloud-Provider stehen in der Registry.
// The cloud providers are in the registry.
for _, name := range []string{"elevenlabs", "openai", "edge"} {
p, err := New(name, ChainConfig{})
if err != nil {
@@ -66,38 +66,38 @@ func TestNewRegistry(t *testing.T) {
t.Errorf("New(%q).Name() = %q", name, p.Name())
}
}
if _, err := New("gibtsnicht", ChainConfig{}); err == nil {
t.Error("New(\"gibtsnicht\") soll fehlschlagen")
if _, err := New("doesnotexist", ChainConfig{}); err == nil {
t.Error("New(\"doesnotexist\") should fail")
} else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") {
t.Errorf("New-Fehler soll verfügbare Provider nennen: %v", err)
t.Errorf("New error should name the available providers: %v", err)
}
}
// TestAvailableOhneTTS: leere PATH-Umgebung → Available false.
func TestAvailableOhneTTS(t *testing.T) {
// TestAvailableWithoutTTS: empty PATH environment → Available false.
func TestAvailableWithoutTTS(t *testing.T) {
emptyPATH(t)
if (OSBuiltin{}).Available(context.Background()) {
t.Error("Available() = true ohne jedes TTS-Binary, want false")
t.Error("Available() = true without any TTS binary, want false")
}
}
// TestSynthesizeOhneTTS: fehlt byte-fähiges TTS, muss der Fehler
// ErrNoByteOutput sein (kein Panik, kein anderer Fehlertyp).
func TestSynthesizeOhneTTS(t *testing.T) {
// TestSynthesizeWithoutTTS: if byte-capable TTS is missing, the error must
// be ErrNoByteOutput (no panic, no other error type).
func TestSynthesizeWithoutTTS(t *testing.T) {
emptyPATH(t)
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize ohne TTS: kein Fehler")
t.Fatal("Synthesize without TTS: no error")
}
if !errors.Is(err, ErrNoByteOutput) {
t.Fatalf("Synthesize-Fehler = %v, want ErrNoByteOutput", err)
t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err)
}
}
// TestSynthesizeMitFakeEspeak: ein gefaktes espeak liefert WAV-Bytes auf
// stdout; Synthesize muss genau diese Bytes durchreichen und die Stimme
// (-v <lang>) sowie den Text übergeben bekommen haben.
func TestSynthesizeMitFakeEspeak(t *testing.T) {
// TestSynthesizeWithFakeEspeak: a faked espeak returns WAV bytes on stdout;
// Synthesize must pass exactly these bytes through and must have received
// the voice (-v <lang>) and the text.
func TestSynthesizeWithFakeEspeak(t *testing.T) {
dir := t.TempDir()
wav := filepath.Join(dir, "probe.wav")
if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil {
@@ -108,95 +108,95 @@ func TestSynthesizeMitFakeEspeak(t *testing.T) {
cat "`+wav+`"`)
prependPATH(t, dir)
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo Welt", "de")
data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello world", "de")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(data) != string(minimalWAV) {
t.Errorf("Synthesize lieferte %d Bytes, want die WAV-Bytes des Fake-espeak (%d)", len(data), len(minimalWAV))
t.Errorf("Synthesize returned %d bytes, want the WAV bytes of the fake espeak (%d)", len(data), len(minimalWAV))
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("Fake-espeak wurde nicht aufgerufen: %v", err)
t.Fatalf("fake espeak was not called: %v", err)
}
if got := string(args); got != "-v de --stdout Hallo Welt\n" {
t.Errorf("Fake-espeak ARGS = %q, want %q", got, "-v de --stdout Hallo Welt\n")
if got := string(args); got != "-v de --stdout Hello world\n" {
t.Errorf("fake espeak ARGS = %q, want %q", got, "-v de --stdout Hello world\n")
}
if !(OSBuiltin{}).Available(context.Background()) {
t.Error("Available() = false trotz espeak im PATH")
t.Error("Available() = false despite espeak in PATH")
}
}
// TestSynthesizeLeererText ist ein klarer Fehler.
func TestSynthesizeLeererText(t *testing.T) {
// TestSynthesizeEmptyText is a clear error.
func TestSynthesizeEmptyText(t *testing.T) {
if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil {
t.Error("Synthesize(\"\") soll fehlschlagen")
t.Error("Synthesize(\"\") should fail")
}
}
// TestSynthesizeFehlgeschlagenesEspeak: espeak crasht (exit 1) → klarer
// Fehler, kein ErrNoByteOutput.
func TestSynthesizeFehlgeschlagenesEspeak(t *testing.T) {
// TestSynthesizeFailingEspeak: espeak crashes (exit 1) → clear error, not
// ErrNoByteOutput.
func TestSynthesizeFailingEspeak(t *testing.T) {
dir := emptyPATH(t)
fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`)
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hallo", "de")
_, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de")
if err == nil {
t.Fatal("Synthesize mit crashendem espeak: kein Fehler")
t.Fatal("Synthesize with crashing espeak: no error")
}
if errors.Is(err, ErrNoByteOutput) {
t.Errorf("Crashender espeak soll kein ErrNoByteOutput sein: %v", err)
t.Errorf("crashing espeak must not be ErrNoByteOutput: %v", err)
}
if !strings.Contains(err.Error(), "boom") {
t.Errorf("Fehler soll stderr des Prozesses enthalten: %v", err)
t.Errorf("error should contain the process stderr: %v", err)
}
}
// TestSpeakDirectMitFakeSpdSay: SpeakDirect ruft spd-say blockierend
// (-w) mit Sprache (-l <lang>) und Text auf.
func TestSpeakDirectMitFakeSpdSay(t *testing.T) {
// TestSpeakDirectWithFakeSpdSay: SpeakDirect calls spd-say blocking (-w)
// with language (-l <lang>) and text.
func TestSpeakDirectWithFakeSpdSay(t *testing.T) {
dir := emptyPATH(t)
seen := filepath.Join(dir, "seen.txt")
fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
prependPATH(t, dir)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo Welt", "de"); err != nil {
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello world", "de"); err != nil {
t.Fatalf("SpeakDirect: %v", err)
}
args, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("Fake-spd-say wurde nicht aufgerufen: %v", err)
t.Fatalf("fake spd-say was not called: %v", err)
}
if got := string(args); got != "-w -l de Hallo Welt\n" {
t.Errorf("Fake-spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt\n")
if got := string(args); got != "-w -l de Hello world\n" {
t.Errorf("fake spd-say ARGS = %q, want %q", got, "-w -l de Hello world\n")
}
}
// TestSpeakDirectOhneSpdSay ist ein klarer Fehler.
func TestSpeakDirectOhneSpdSay(t *testing.T) {
// TestSpeakDirectWithoutSpdSay is a clear error.
func TestSpeakDirectWithoutSpdSay(t *testing.T) {
emptyPATH(t)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hallo", "de"); err == nil {
t.Error("SpeakDirect ohne spd-say soll fehlschlagen")
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello", "de"); err == nil {
t.Error("SpeakDirect without spd-say should fail")
}
}
// TestSpeakDirectEchtesSpdSay läuft nur, wenn TALK_TEST_REAL_SPD=1 gesetzt
// ist (erzeugt echte, hörbare Ausgabe über speech-dispatcher — einmalige
// manuelle Abnahme; Standard-Testläufe bleiben stumm).
func TestSpeakDirectEchtesSpdSay(t *testing.T) {
// TestSpeakDirectRealSpdSay runs only when TALK_TEST_REAL_SPD=1 is set (it
// produces real, audible output through speech-dispatcher; one-off manual
// acceptance, standard test runs stay silent).
func TestSpeakDirectRealSpdSay(t *testing.T) {
if os.Getenv("TALK_TEST_REAL_SPD") != "1" {
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_SPD=1 ausführen")
t.Skip("skipped (audible); run with TALK_TEST_REAL_SPD=1")
}
if _, err := exec.LookPath("spd-say"); err != nil {
t.Skip("spd-say nicht installiert")
t.Skip("spd-say not installed")
}
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Sprachtest über speech dispatcher", "de"); err != nil {
t.Fatalf("SpeakDirect gegen echtes spd-say: %v", err)
if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Speech test via speech dispatcher", "de"); err != nil {
t.Fatalf("SpeakDirect against real spd-say: %v", err)
}
}
// minimalWAV ist eine minimal gültige RIFF/WAVE-Datei (PCM 16 Bit, mono,
// 8000 Hz) mit vier Samples — handgebaut nach der RIFF-Spezifikation,
// unabhängig vom Parser im audio-Paket.
// minimalWAV is a minimal valid RIFF/WAVE file (PCM 16 bit, mono, 8000 Hz)
// with four samples, hand-built per the RIFF specification, independent of
// the parser in the audio package.
var minimalWAV = []byte{
'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8)
'W', 'A', 'V', 'E',
@@ -208,5 +208,5 @@ var minimalWAV = []byte{
2, 0, // BlockAlign
16, 0, // Bits
'd', 'a', 't', 'a', 8, 0, 0, 0,
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 Samples
0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 samples
}
+45 -46
View File
@@ -8,69 +8,68 @@ import (
"strings"
)
// DefaultName ist der Provider, den New("") liefert (Einzelkonstruktion,
// z. B. für doctor). Die Default-Ansage-Kette ab Task 03 steht in
// DefaultOrder (chain.go): elevenlabs → openai → edge → osbuiltin.
// DefaultName is the provider New("") returns (single construction, e.g.
// for doctor). The default announcement chain lives in DefaultOrder
// (chain.go): elevenlabs → openai → edge → osbuiltin.
const DefaultName = "osbuiltin"
// ErrNoByteOutput meldet, dass die Plattform keine über Synthesize
// beziehbaren Audio-Bytes liefern kann — konkret Linux mit speech-dispatcher
// (spd-say) ohne espeak: spd-say kann Audio nur selbst über den Daemon
// abspielen, nicht in einen Prozess schreiben. Aufrufer sollen auf
// DirectSpeaker ausweichen (siehe dort).
// ErrNoByteOutput reports that the platform cannot provide audio bytes via
// Synthesize; concretely Linux with speech-dispatcher (spd-say) but without
// espeak: spd-say can only play audio itself through the daemon, it cannot
// write it to a process. Callers should fall back to DirectSpeaker (see
// there).
var ErrNoByteOutput = errors.New(
"diese Plattform kann keine Audio-Bytes liefern (Linux ohne espeak; spd-say spielt nur direkt)")
"this platform cannot provide audio bytes (Linux without espeak; spd-say only speaks directly)")
// Provider ist der Vertrag aller TTS-Backends von talk.
// Provider is the contract of all talk TTS backends.
//
// Synthesize liefert dekodierbare Audio-Bytes; das Format ist von der
// Implementierung dokumentiert: osbuiltin liefert RIFF/WAVE (PCM), die
// Cloud-Provider elevenlabs, openai und edge liefern MP3 — beides wird vom
// audio-Paket (oto-Sink) verstanden.
// Synthesize returns decodable audio bytes; the format is documented by the
// implementation: osbuiltin returns RIFF/WAVE (PCM), the cloud providers
// elevenlabs, openai and edge return MP3. Both are understood by the audio
// package (oto sink).
type Provider interface {
// Name liefert den eindeutigen Providernamen (CLI --provider, Config).
// Name returns the unique provider name (CLI --provider, config).
Name() string
// Available meldet, ob der Provider auf diesem System überhaupt nutzbar
// ist (z. B. Binary oder Key vorhanden). Muss billig und ohne Neben-
// wirkungen sein; false heißt: Kette soll überspringen werden.
// Available reports whether the provider is usable on this system at all
// (e.g. binary or key present). Must be cheap and side-effect free;
// false means: the chain should skip this tier.
Available(ctx context.Context) bool
// Synthesize erzeugt Audio-Bytes für text in der Sprache lang.
// Synthesize produces audio bytes for text in language lang.
Synthesize(ctx context.Context, text, lang string) ([]byte, error)
}
// DirectSpeaker ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus (kein Bestandteil des Basisvertrags): Provider,
// die Text ohne Umweg über Synthesize-Bytes selbst hörbar ausgeben können.
// Notwendig auf Linux-Systemen, deren OS-TTS (speech-dispatcher) keine
// Audio-Bytes an Prozesse liefern kann — dort spielt osbuiltin.SpeakDirect
// über spd-say selbst ab (blockierend bis zum Ende der Ansage).
// DirectSpeaker is a documented optional extension beyond the Provider
// interface (not part of the base contract): providers that can make text
// audible themselves without going through Synthesize bytes. Required on
// Linux systems whose OS TTS (speech-dispatcher) cannot hand audio bytes to
// processes; there osbuiltin.SpeakDirect plays via spd-say itself (blocking
// until the announcement has finished).
//
// In der Kette (chain.go) ist SpeakDirect die letzte Stufe, wenn kein
// Provider Bytes liefern kann; Chain implementiert DirectSpeaker selbst und
// reicht an den Stufen-Provider durch.
// In the chain (chain.go) SpeakDirect is the last tier when no provider can
// deliver bytes; Chain implements DirectSpeaker itself and forwards to the
// tier provider.
type DirectSpeaker interface {
// SpeakDirect gibt text in der Sprache lang direkt über das OS-TTS aus
// und blockiert bis zum Ende der Wiedergabe (oder bis ctx abgebrochen
// wird).
// SpeakDirect speaks text in language lang directly through the OS TTS
// and blocks until playback has finished (or ctx is cancelled).
SpeakDirect(ctx context.Context, text, lang string) error
}
// VoicePinner ist eine dokumentierte optionale Erweiterung über das
// Provider-Interface hinaus: Provider, die eine explizite Voice-ID pinnen
// können (das CLI-Flag --voice). Die gepinnte Stimme schlägt das
// voice.<provider>.<lang>-Mapping der config.toml und die eingebauten
// neutralen Defaults. Die Kette ruft PinVoice beim Bau auf alle Stufen auf,
// die die Erweiterung implementieren (Stufen ohne Voice-Begriff, z. B.
// osbuiltin, loggen einen Hinweis statt still zu ignorieren).
// VoicePinner is a documented optional extension beyond the Provider
// interface: providers that can pin an explicit voice ID (the CLI flag
// --voice). The pinned voice beats the voice.<provider>.<lang> mapping of
// config.toml and the built-in neutral defaults. The chain calls PinVoice
// at construction on all tiers that implement the extension (tiers without
// a notion of voice, e.g. osbuiltin, log a note instead of silently
// ignoring it).
type VoicePinner interface {
PinVoice(id string)
}
// registry bildet Providernamen auf Konstruktoren; NewChain und New bauen
// darüber. Die Konstruktoren erhalten die ChainConfig (Endpunkte, Voices,
// Keys via Getenv), damit Kette und Daemon (Task 04) dieselbe Quelle haben.
// registry maps provider names to constructors; NewChain and New build
// through it. The constructors receive the ChainConfig (endpoints, voices,
// keys via Getenv) so that the chain and the daemon share one source.
var registry = map[string]func(ChainConfig) Provider{
"elevenlabs": func(cfg ChainConfig) Provider { return NewElevenLabs(cfg) },
"openai": func(cfg ChainConfig) Provider { return NewOpenAI(cfg) },
@@ -78,7 +77,7 @@ var registry = map[string]func(ChainConfig) Provider{
"osbuiltin": func(ChainConfig) Provider { return OSBuiltin{} },
}
// Names liefert die registrierten Providernamen (sortiert).
// Names returns the registered provider names (sorted).
func Names() []string {
names := make([]string, 0, len(registry))
for name := range registry {
@@ -88,16 +87,16 @@ func Names() []string {
return names
}
// New liefert den Provider mit dem angegebenen Namen (Einzelkonstruktion);
// "" wählt DefaultName. Für die Ansage-Kette (Fallback) NewChain verwenden.
// Unbekannte Namen sind ein klarer Fehler.
// New returns the provider with the given name (single construction); ""
// selects DefaultName. For the announcement chain (fallback) use NewChain.
// Unknown names are a clear error.
func New(name string, cfg ChainConfig) (Provider, error) {
if name == "" {
name = DefaultName
}
ctor, ok := registry[name]
if !ok {
return nil, fmt.Errorf("unbekannter oder noch nicht implementierter Provider %q (verfügbar: %s)",
return nil, fmt.Errorf("unknown provider %q (available: %s)",
name, strings.Join(Names(), ", "))
}
if cfg.Getenv == nil {
+21 -22
View File
@@ -2,28 +2,27 @@ package provider
import "sort"
// voiceDefaults sind die neutralen Repo-Defaults je Provider und Sprache
// (Teil des öffentlichen Vertrags: `talk voices` aus Task 08 listet sie,
// überschrieben werden sie durch voice.<provider>.<lang> in der config.toml
// oder das CLI-Flag --voice).
// voiceDefaults are the neutral repo defaults per provider and language
// (part of the public contract: `talk voices` lists them; they are
// overridden by voice.<provider>.<lang> in config.toml or the CLI flag
// --voice).
//
// Entscheidungsnotiz ElevenLabs-Stimme: Spec und Plan führen die persönliche
// Stimme KXxZd16DiBqt82nbarJx ausdrücklich als PRIVATEN Default des Nutzers
// („nicht Repo-Default", Spec Further Notes); der Repo-Default ist deshalb
// die neutrale, mehrsprachige Standardstimme „Rachel" (21m00Tcm4TlvDq8ikWAM
// aus dem öffentlichen ElevenLabs-Katalog). Die persönliche Stimme gehört in
// die eigene config.toml:
// Decision note on the ElevenLabs voice: spec and plan explicitly treat the
// personal voice KXxZd16DiBqt82nbarJx as the user's PRIVATE default ("not a
// repo default", spec Further Notes); the repo default is therefore the
// neutral, multilingual standard voice "Rachel" (21m00Tcm4TlvDq8ikWAM from
// the public ElevenLabs catalog). The personal voice belongs in one's own
// config.toml:
//
// [voice.elevenlabs]
// de = "KXxZd16DiBqt82nbarJx"
// en = "KXxZd16DiBqt82nbarJx"
//
// OpenAI „alloy" und die Edge-Neural-Stimmen (de-DE-KatjaNeural,
// en-US-AriaNeural) sind die jeweils neutralen Standardstimmen der APIs
// (Edge-Stimmen sind im Task-Brief vorgegeben).
// OpenAI "alloy" and the Edge neural voices (de-DE-KatjaNeural,
// en-US-AriaNeural) are the respective neutral standard voices of the APIs.
var voiceDefaults = map[string]map[string]string{
"elevenlabs": {
"de": "21m00Tcm4TlvDq8ikWAM", // „Rachel", multilingual
"de": "21m00Tcm4TlvDq8ikWAM", // "Rachel", multilingual
"en": "21m00Tcm4TlvDq8ikWAM",
},
"openai": {
@@ -35,19 +34,19 @@ var voiceDefaults = map[string]map[string]string{
"en": "en-US-AriaNeural",
},
"osbuiltin": {
// osbuiltin mappt intern selbst (macOS Anna/Samantha, SAPI-Kultur,
// espeak -v <lang>) und kennt keine Voice-IDs — hier nur der
// Vollständigkeit wegen dokumentiert.
// osbuiltin maps internally on its own (macOS Anna/Samantha, SAPI
// culture, espeak -v <lang>) and has no voice IDs; documented here
// only for completeness.
"de": "",
"en": "",
},
}
// resolveVoice liefert die Voice-ID für einen Provider und eine Sprache.
// Rangfolge: override (CLI-Flag --voice bzw. PinVoice) > Mapping aus der
// config.toml > neutrales Repo-Default > en-Default > alphabetisch erstes
// Default. Ein leerer Rückgabewert heißt: der Provider kennt für diese
// Sprache keine Stimme (osbuiltin) und entscheidet selbst.
// resolveVoice returns the voice ID for a provider and a language.
// Precedence: override (CLI flag --voice or PinVoice) > mapping from
// config.toml > neutral repo default > en default > alphabetically first
// default. An empty result means: the provider has no voice for this
// language (osbuiltin) and decides on its own.
func resolveVoice(name, lang, override string, cfgVoices map[string]string) string {
if override != "" {
return override
+11 -11
View File
@@ -2,9 +2,9 @@ package provider
import "testing"
// TestResolveVoiceRangfolge pinnt die dokumentierte Rangfolge
// --voice-Pin > config-Mapping > Repo-Default > en-Default.
func TestResolveVoiceRangfolge(t *testing.T) {
// TestResolveVoicePrecedence pins the documented precedence
// --voice pin > config mapping > repo default > en default.
func TestResolveVoicePrecedence(t *testing.T) {
cases := []struct {
name string
provider string
@@ -13,14 +13,14 @@ func TestResolveVoiceRangfolge(t *testing.T) {
cfg map[string]string
want string
}{
{"Default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"Default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"Default elevenlabs de (neutral, nicht privat)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"Default openai en", "openai", "en", "", nil, "alloy"},
{"Mapping schlägt Default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"Pin schlägt Mapping", "edge", "de", "Gepinnt", map[string]string{"de": "de-DE-ConradNeural"}, "Gepinnt"},
{"unbekannte Sprache → en-Default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin kennt keine Voice-IDs", "osbuiltin", "de", "", nil, ""},
{"default edge de", "edge", "de", "", nil, "de-DE-KatjaNeural"},
{"default edge en", "edge", "en", "", nil, "en-US-AriaNeural"},
{"default elevenlabs de (neutral, not private)", "elevenlabs", "de", "", nil, "21m00Tcm4TlvDq8ikWAM"},
{"default openai en", "openai", "en", "", nil, "alloy"},
{"mapping beats default", "edge", "de", "", map[string]string{"de": "de-DE-ConradNeural"}, "de-DE-ConradNeural"},
{"pin beats mapping", "edge", "de", "Pinned", map[string]string{"de": "de-DE-ConradNeural"}, "Pinned"},
{"unknown language falls back to en default", "edge", "fr", "", nil, "en-US-AriaNeural"},
{"osbuiltin has no voice IDs", "osbuiltin", "de", "", nil, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+9 -8
View File
@@ -1,9 +1,10 @@
// Package queue implementiert die Ansage-Queue des Daemons: serialisierte
// Abarbeitung (eine Ansage gleichzeitig), Prioritäten (high > normal > low,
// FIFO je Level), now-Items springen vor, dazu den persistenten Mute-State
// (Datei mute im Config-Dir). Seit Task 05 unter dem Anti-Spam-Gesetz
// (rate.go/quiet.go): Rate-Limit — jede gestartete Ansage bucht das Fenster;
// low über dem Budget verfällt, normal parkt bis zum Slot, high darf mit
// Burst-Recht verstoßen — und Ruhezeiten, die still verwerfen oder durch
// einen Toast ersetzen. now:true bricht bewusst aus beiden aus.
// Package queue is the daemon's announcement queue: serialized playback (one
// announcement at a time), priorities (high > normal > low, FIFO per level),
// now items that jump ahead, plus the persistent mute state (file "mute" in
// the config directory). The anti-spam laws (rate.go, quiet.go) sit on top:
// a rate limit where every started announcement books the window (low over
// budget is discarded, normal parks until the slot, high may break it with
// burst rights), quiet hours that discard silently or toast instead, a text
// length cap and a maximum queue age. now:true deliberately breaks out of
// all of them.
package queue
+50 -50
View File
@@ -6,9 +6,9 @@ import (
"time"
)
// TestTruncateText: Kürzung auf max Runen an einer Wortgrenze (nicht in der
// ersten Hälfte), ohne hängende Satzzeichen; kurze Texte und max 0 bleiben
// unangetastet; Runen zählen, nicht Bytes.
// TestTruncateText: cut to max runes at a word boundary (not in the first
// half), without dangling punctuation; short texts and max 0 stay untouched;
// runes count, not bytes.
func TestTruncateText(t *testing.T) {
cases := []struct {
name string
@@ -17,13 +17,13 @@ func TestTruncateText(t *testing.T) {
want string
truncated bool
}{
{"kurz bleibt", "Build fertig", 400, "Build fertig", false},
{"genau max bleibt", "abcde", 5, "abcde", false},
{"unbegrenzt", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"wortgrenze", "Der Build ist fertig und bereit", 14, "Der Build ist", true},
{"satzzeichen weg", "Fertig, aber dann kam noch mehr", 8, "Fertig", true},
{"keine wortgrenze in zweiter hälfte → hart", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlaute als eine rune", "Übergröße ärgert", 9, "Übergröße", true},
{"short stays", "Build done", 400, "Build done", false},
{"exactly max stays", "abcde", 5, "abcde", false},
{"unlimited", strings.Repeat("x", 1000), 0, strings.Repeat("x", 1000), false},
{"word boundary", "The build is done and ready", 14, "The build is", true},
{"punctuation dropped", "Done, but then there was more", 6, "Done", true},
{"no boundary in second half → hard cut", "abcdefghijklmnop qrs", 10, "abcdefghij", true},
{"umlauts count as one rune", "Übergröße ärgert", 9, "Übergröße", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -32,83 +32,83 @@ func TestTruncateText(t *testing.T) {
t.Errorf("truncateText(%q, %d) = %q/%v, want %q/%v", c.in, c.max, got, tr, c.want, c.truncated)
}
if c.max > 0 && len([]rune(got)) > c.max {
t.Errorf("Ergebnis %q länger als %d Runen", got, c.max)
t.Errorf("result %q longer than %d runes", got, c.max)
}
})
}
}
// TestEnqueueKuerztText: Das Gesetz MaxText greift beim Eintritt — die
// gekürzte Fassung wird gesprochen, Admission meldet Truncated.
func TestEnqueueKuerztText(t *testing.T) {
g := neueSpielerin()
// TestEnqueueTruncatesText: the MaxText law applies on admission; the
// shortened version is spoken and Admission reports Truncated.
func TestEnqueueTruncatesText(t *testing.T) {
g := newSpeaker()
q := New(Laws{MaxText: 20}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "Dieser Text ist deutlich länger als zwanzig Zeichen"})
adm, err := q.Enqueue(Item{Text: "This text is clearly longer than twenty characters"})
if err != nil || adm.ID == "" || !adm.Truncated {
t.Fatalf("Enqueue = %+v, %v; want aufgenommen + Truncated", adm, err)
t.Fatalf("Enqueue = %+v, %v; want admitted + Truncated", adm, err)
}
items := g.warteBis(t, 1)
if items[0].Text != "Dieser Text ist" {
t.Errorf("gesprochen = %q, want gekürzte Fassung", items[0].Text)
items := g.waitUntil(t, 1)
if items[0].Text != "This text is clearly" {
t.Errorf("spoken = %q, want the shortened version", items[0].Text)
}
g.step <- struct{}{}
adm, _ = q.Enqueue(Item{Text: "kurz"})
adm, _ = q.Enqueue(Item{Text: "short"})
if adm.Truncated {
t.Error("kurzer Text als gekürzt gemeldet")
t.Error("short text reported as truncated")
}
g.warteBis(t, 2)
g.waitUntil(t, 2)
g.step <- struct{}{}
}
// TestDecideExpired: Ein Item, das länger als MaxAge gewartet hat, wird
// verworfen — vor Ruhezeit und Rate-Limit, aber nach now (now bricht aus).
// TestDecideExpired: an item that waited longer than MaxAge is discarded,
// before quiet hours and rate limit but after now (now breaks out).
func TestDecideExpired(t *testing.T) {
laws := Laws{MaxAge: time.Minute, RateLimit: time.Hour}
now := time.Now()
alt := Item{Text: "alt", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
frisch := Item{Text: "frisch", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
old := Item{Text: "old", Priority: PriorityNormal, enqueued: now.Add(-2 * time.Minute)}
fresh := Item{Text: "fresh", Priority: PriorityNormal, enqueued: now.Add(-10 * time.Second)}
if v := laws.decide(alt, now, true); v != verdictDiscardExpired {
t.Errorf("altes Item bei freiem Slot = %v, want verdictDiscardExpired", v)
if v := laws.decide(old, now, true); v != verdictDiscardExpired {
t.Errorf("old item with open slot = %v, want verdictDiscardExpired", v)
}
if v := laws.decide(frisch, now, true); v != verdictPlay {
t.Errorf("frisches Item bei freiem Slot = %v, want verdictPlay", v)
if v := laws.decide(fresh, now, true); v != verdictPlay {
t.Errorf("fresh item with open slot = %v, want verdictPlay", v)
}
if v := laws.decide(frisch, now, false); v != verdictWait {
t.Errorf("frisches Item ohne Slot = %v, want verdictWait", v)
if v := laws.decide(fresh, now, false); v != verdictWait {
t.Errorf("fresh item without slot = %v, want verdictWait", v)
}
altNow := alt
altNow.Now = true
if v := laws.decide(altNow, now, false); v != verdictPlay {
t.Errorf("altes now-Item = %v, want verdictPlay (now bricht aus)", v)
oldNow := old
oldNow.Now = true
if v := laws.decide(oldNow, now, false); v != verdictPlay {
t.Errorf("old now item = %v, want verdictPlay (now breaks out)", v)
}
if v := (Laws{}).decide(alt, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unbegrenzt) verwirft altes Item: %v", v)
if v := (Laws{}).decide(old, now, true); v != verdictPlay {
t.Errorf("MaxAge 0 (unlimited) discards old item: %v", v)
}
}
// TestGeparktesItemVerfaellt: Ein normal-Item parkt auf den Rate-Limit-Slot;
// ist es beim Fälligwerden älter als MaxAge, wird es verworfen (Protokoll
// nennt max_age) und nicht mehr gesprochen.
func TestGeparktesItemVerfaellt(t *testing.T) {
// TestParkedItemExpires: a normal item parks for the rate-limit slot; if it
// is older than MaxAge when it comes due, it is discarded (log names
// max_age) and never spoken.
func TestParkedItemExpires(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{RateLimit: 300 * time.Millisecond, MaxAge: 150 * time.Millisecond}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "erste"}) // bucht das Fenster
g.warteBis(t, 1)
q.Enqueue(Item{Text: "first"}) // books the window
g.waitUntil(t, 1)
g.step <- struct{}{}
adm, _ := q.Enqueue(Item{Text: "parkt zu lange"}) // parkt ~300 ms > MaxAge
adm, _ := q.Enqueue(Item{Text: "parks too long"}) // parks ~300 ms > MaxAge
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("beim Eintritt darf nichts verfallen: %+v", adm)
t.Fatalf("nothing may expire on admission: %+v", adm)
}
waitFor(t, func() bool { return strings.Contains(logb.String(), "max_age") })
g.keineWeitere(t)
g.noFurther(t)
if !strings.Contains(logb.String(), adm.ID) {
t.Errorf("Protokoll nennt die verfallene Ansage nicht:\n%s", logb.String())
t.Errorf("log does not name the expired announcement:\n%s", logb.String())
}
}
+16 -20
View File
@@ -7,31 +7,27 @@ import (
"strings"
)
// MuteFileName ist der Name der Mute-Datei im Config-Verzeichnis (bindender
// Vertrag: „persistenten Mute-State toggeln (Datei mute im Config-Dir)").
// MuteFileName is the mute file's name inside the config directory.
const MuteFileName = "mute"
// ErrNoMutePath meldet einen Set-Versuch ohne konfigurierten Datei-Pfad
// (Config-Dir nicht bestimmbar — der Daemon deaktiviert dann Mute mit einer
// Start-Warnung statt zu scheitern).
var ErrNoMutePath = errors.New("mute: kein Datei-Pfad konfiguriert (Config-Dir nicht bestimmbar)")
// ErrNoMutePath reports a Set attempt without a configured file path (config
// directory unknown; the daemon then runs without mute and warns at start).
var ErrNoMutePath = errors.New("mute: no file path configured (config directory unknown)")
// MuteStore verwaltet den persistenten Mute-State als Datei im
// Config-Verzeichnis: Inhalt „true" = stumm, alles andere (oder keine Datei)
// = nicht stumm.
// MuteStore manages the persistent mute state as a file in the config
// directory: content "true" = muted, anything else (or no file) = not muted.
//
// Der Daemon liest die Datei bei jeder /speak-Anfrage neu — dadurch wirkt
// auch ein im Degraded Mode (Daemon tot) von der CLI direkt geschriebener
// State sofort, und der Zustand überlebt Daemon-Restarts.
// The daemon re-reads the file on every /speak request, so a state written
// by the CLI while no daemon runs takes effect immediately, and the state
// survives daemon restarts.
type MuteStore struct {
// Path ist der vollständige Pfad der Mute-Datei. Leer (kein Config-Dir
// bestimmbar): Muted() ist immer false, Set ein Fehler.
// Path is the mute file's full path. Empty (no config directory): Muted
// is always false and Set an error.
Path string
}
// Muted meldet den persistenten Mute-State. Eine fehlende oder unlesbare
// Datei heißt: nicht stumm (Fehlstart ohne Config-Dir wird nie „stumm aus
// Versehen").
// Muted reports the persistent mute state. A missing or unreadable file
// means not muted: a broken setup must never be "accidentally silent".
func (s MuteStore) Muted() bool {
if s.Path == "" {
return false
@@ -43,9 +39,9 @@ func (s MuteStore) Muted() bool {
return strings.TrimSpace(string(b)) == "true"
}
// Set schreibt den Mute-State atomar (temporäre Datei + Rename, damit
// Daemon und CLI nie eine halb geschriebene Datei lesen) und legt das
// Config-Verzeichnis bei Bedarf an.
// Set writes the mute state atomically (temp file + rename, so daemon and
// CLI never read a half-written file) and creates the config directory if
// needed.
func (s MuteStore) Set(muted bool) error {
if s.Path == "" {
return ErrNoMutePath
+122 -128
View File
@@ -12,18 +12,18 @@ import (
"time"
)
// Priority eines Queue-Items — öffentlicher Vertrag der HTTP-API
// (low | normal | high; leer wird als normal behandelt).
// Priority of a queue item; public contract of the HTTP API
// (low | normal | high; empty is treated as normal).
type Priority string
// Erlaubte Prioritäts-Werte (Spec: high > normal > low, FIFO je Level).
// Allowed priority values (high > normal > low, FIFO per level).
const (
PriorityLow Priority = "low"
PriorityNormal Priority = "normal"
PriorityHigh Priority = "high"
)
// Valid meldet, ob p ein erlaubter Prioritäts-Wert ist.
// Valid reports whether p is an allowed priority value.
func (p Priority) Valid() bool {
switch p {
case PriorityLow, PriorityNormal, PriorityHigh:
@@ -33,43 +33,42 @@ func (p Priority) Valid() bool {
}
}
// ErrClosed meldet einen Enqueue-Versuch nach Close (Daemon fährt herunter).
var ErrClosed = errors.New("queue: bereits geschlossen")
// ErrClosed reports an Enqueue attempt after Close (daemon shutting down).
var ErrClosed = errors.New("queue: already closed")
// Item ist eine Ansage in der Warteschlange. Alle Felder kommen aus der
// HTTP-API (POST /speak); ID und Ablauf-Verwaltung liegen bei der Queue.
// Item is an announcement in the queue. All fields come from the HTTP API
// (POST /speak); ID and lifecycle belong to the queue.
type Item struct {
// ID ist die kurze Ansage-Kennung (Antwort von POST /speak; Grundlage
// des --wait-Pollings der CLI). Wird von Enqueue vergeben, wenn leer.
// ID is the short announcement identifier (returned by POST /speak; the
// CLI polls it for --wait). Assigned by Enqueue when empty.
ID string
// Text ist der Ansage-Text.
// Text is the announcement text.
Text string
// Lang ist die Sprach-Pin (de | en); leer = automatische Erkennung
// durch den Daemon (langdetect).
// Lang pins the language (de | en); empty = detected by the daemon.
Lang string
// Provider pinnt die Provider-Kette auf eine Stufe (--provider); leer
// = konfigurierte Default-Kette.
// Provider pins the chain to one tier (--provider); empty = the
// configured default chain.
Provider string
// Voice pinnt eine Voice-ID (--voice); leer = Voice-Mapping/Defaults.
// Voice pins a voice ID (--voice); empty = voice mapping/defaults.
Voice string
// Priority steuert die Abarbeitungs-Reihenfolge (high > normal > low,
// FIFO je Level).
// Priority controls processing order (high > normal > low, FIFO per
// level).
Priority Priority
// Now lässt die Ansage vor alle wartenden Items springen (auch vor
// high) — sie unterbricht aber nie die laufende Ansage.
// Now lets the announcement jump ahead of every waiting item (including
// high); it never interrupts the announcement that is playing.
Now bool
seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
enqueued time.Time // Eintrittszeit (Basis für Laws.MaxAge)
seq uint64 // enqueue order: FIFO tiebreaker within a level
enqueued time.Time // admission time (basis for Laws.MaxAge)
}
// rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
// rank returns the processing class: now over high over normal over low.
func (it Item) rank() int {
if it.Now {
return 3
@@ -84,56 +83,53 @@ func (it Item) rank() int {
}
}
// Speaker spielt ein Item ab. Im Daemon: Provider-Kette bauen und Speak
// aufrufen (Synthese mit Fallback + Wiedergabe über den Audio-Sink — die
// Kette aus internal/provider wird unverändert wiederverwendet). ctx wird
// abgebrochen, wenn die Ansage abgebrochen (POST /cancel) oder der Daemon
// heruntergefahren wird; die Kette bricht ihre Netz- und Prozess-Arbeit
// dann ab. Der Rückgabe-Fehler wird protokolliert, nie an die API gereicht.
// Speaker plays an item. In the daemon: build the provider chain and call
// Speak (synthesis with fallback plus playback through the audio sink). ctx
// is cancelled when the announcement is cancelled (POST /cancel) or the
// daemon shuts down; the chain then aborts its network and process work.
// The returned error is logged, never passed to the API.
type Speaker func(ctx context.Context, item Item) error
// Queue ist die serialisierte Ansage-Queue des Daemons: genau eine Ansage
// gleichzeitig, Reihenfolge now > high > normal > low (FIFO je Level) —
// seit Task 05 unter dem Anti-Spam-Gesetz (Laws): normal parkt bis zum
// Rate-Limit-Slot, low wird über dem Budget verworfen, high darf mit
// Burst-Recht verstoßen, now bricht bewusst aus, und Ruhezeiten verwerfen
// bzw. toasten statt Audio.
// Queue is the daemon's serialized announcement queue: exactly one
// announcement at a time, order now > high > normal > low (FIFO per level),
// under the anti-spam laws: normal parks until the rate-limit slot, low
// over budget is discarded, high may break the limit with burst rights, now
// deliberately breaks out, quiet hours discard or toast instead of audio,
// stale items expire.
//
// Die Synchronisation ist bewusst simpel und offensichtlich korrekt statt
// clever: EIN Mutex + EIN Cond schützen alles (Items, aktuelles Item,
// Closed-Flag, cancelCur, Slot-Wecker). Der Worker ist der einzige, der
// Items entnimmt; take und das Setzen von current/cancelCur geschehen unter
// demselben Lock, damit ein Item nie zwischen „wartet" und „läuft" der
// Cancelierung entwischen kann. Cancel/Close rufen cancelCur unter dem Lock
// ab — der Worker bemerkt die Abbrechung in seiner speak-Funktion über ctx.
// Geparkte Items bleiben in der Warteschlange liegen; ein Slot-Wecker
// (armWakeLocked) broadcastet zum Fenster-Ablauf, damit der Worker auch ohne
// neuen Enqueue aufwacht — die Wait-Bedingung prüft jedes Mal die Gesetze
// neu, verlorene Signale sind dadurch harmlos.
// Synchronization is deliberately simple and obviously correct rather than
// clever: ONE mutex and ONE cond protect everything (items, current item,
// closed flag, cancelCur, slot timer). The worker is the only one taking
// items; take and setting current/cancelCur happen under the same lock, so
// an item can never slip past cancellation between "waiting" and "playing".
// Cancel/Close call cancelCur under the lock; the worker notices via ctx in
// its speak function. Parked items stay in the queue; a slot timer
// (armWakeLocked) broadcasts when the window elapses so the worker wakes
// without a new Enqueue. The wait condition re-evaluates the laws every
// time, so lost signals are harmless.
type Queue struct {
laws Laws
limit *RateLimiter
speak Speaker
log io.Writer
toast func(Item) // Ruhezeit-Toast (optional; gesetzt vor dem ersten Enqueue)
toast func(Item) // quiet-hours toast (optional; set before the first Enqueue)
mu sync.Mutex
notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei
notEmpty *sync.Cond // Signal: new item; Broadcast: Close / slot open
items []Item
current *Item
cancelCur context.CancelFunc
seq uint64
closed bool
wake *time.Timer // weckt den geparkten Worker zum Slot-Termin
wakeAt time.Time // Termin des laufenden Weckers
done chan struct{} // geschlossen, wenn der Worker-Loop endet
wake *time.Timer // wakes the parked worker at the slot time
wakeAt time.Time // time of the armed timer
done chan struct{} // closed when the worker loop ends
}
// New startet die Queue mit ihrem Worker-Loop (eine Ansage gleichzeitig).
// laws trägt die Anti-Spam-Gesetze (Nullwert = alles erlaubt; der Daemon
// liefert die Config-Gesetze). log ist optional (nil = still) und empfängt
// die Lebenslauf-Zeilen der Items. Close beendet den Loop und wartet auf
// sein Ende.
// New starts the queue with its worker loop (one announcement at a time).
// laws carries the anti-spam laws (zero value = everything allowed; the
// daemon supplies the configured laws). log is optional (nil = silent) and
// receives the items' lifecycle lines. Close ends the loop and waits for it.
func New(laws Laws, speak Speaker, log io.Writer) *Queue {
q := &Queue{
laws: laws,
@@ -147,24 +143,24 @@ func New(laws Laws, speak Speaker, log io.Writer) *Queue {
return q
}
// OnToast setzt die Benachrichtigung, die in der Ruhezeit mit quiet_mode =
// "toast" statt Audio ausgelöst wird (Daemon: Desktop-Notification). Ohne
// Setzen bleibt es bei der Protokollzeile. Vor dem ersten Enqueue setzen.
// OnToast sets the notification fired during quiet hours with quiet_mode =
// "toast" instead of audio (daemon: desktop notification). Unset, only the
// log line remains. Set it before the first Enqueue.
func (q *Queue) OnToast(fn func(Item)) { q.toast = fn }
// fireToast ruft die Toast-Benachrichtigung auf (nie unter dem Mutex).
// fireToast calls the toast hook (never under the mutex).
func (q *Queue) fireToast(item Item) {
if q.toast != nil {
q.toast(item)
}
}
// Enqueue stellt item in die Warteschlange und liefert die Aufnahme-
// Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben:
// kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget
// werden schon beim Eintritt entschieden (die 202-Antwort von POST /speak
// nennt den Grund); alles andere entscheidet der Worker, wenn das Item
// fällig ist. Nach Close ist Enqueue ein Fehler.
// Enqueue puts item into the queue and returns the admission decision:
// admitted items carry their ID (assigned when empty: short and random).
// Quiet-hours discard/toast and low over the window budget are decided on
// admission (the 202 response of POST /speak names the reason); everything
// else is decided by the worker when the item comes due. After Close,
// Enqueue is an error.
func (q *Queue) Enqueue(item Item) (Admission, error) {
q.mu.Lock()
if q.closed {
@@ -176,24 +172,24 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
}
var truncated bool
if item.Text, truncated = truncateText(item.Text, q.laws.MaxText); truncated {
defer q.logf("queue: ansage (%q …) auf %d Zeichen gekürzt (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
defer q.logf("queue: announcement (%q …) truncated to %d characters (max_text)", firstRunes(item.Text, 40), q.laws.MaxText)
}
item.enqueued = time.Now()
switch q.laws.decide(item, item.enqueued, q.limit.Allow()) {
case verdictDiscardQuiet:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität %s) verworfen — ruhezeit %s aktiv (modus discard)",
q.logf("queue: announcement (%q, priority %s) discarded: quiet hours %s active (mode discard)",
item.Text, item.Priority, q.laws.Quiet)
return Admission{Discarded: DiscardQuietHours}, nil
case verdictToast:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
q.logf("queue: announcement (%q, priority %s) replaced by toast: quiet hours %s active (mode toast)",
item.Text, item.Priority, q.laws.Quiet)
q.fireToast(item)
return Admission{Discarded: DiscardQuietHours, Toast: true}, nil
case verdictDiscardRate:
q.mu.Unlock()
q.logf("queue: ansage (%q, priorität low) verworfen — rate-limit %s",
q.logf("queue: announcement (%q, priority low) discarded: rate limit %s",
item.Text, q.laws.RateLimit)
return Admission{Discarded: DiscardRateLimit}, nil
}
@@ -208,7 +204,7 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
return Admission{ID: item.ID, Truncated: truncated}, nil
}
// firstRunes liefert die ersten n Runen von s (Protokoll-Vorschau).
// firstRunes returns the first n runes of s (log preview).
func firstRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
@@ -217,8 +213,8 @@ func firstRunes(s string, n int) string {
return string(r[:n])
}
// Pending liefert eine Momentaufnahme der wartenden Items in
// Abarbeitungs-Reihenfolge (bestes Item zuerst) — Basis für GET /status.
// Pending returns a snapshot of the waiting items in processing order (best
// item first); basis for GET /status.
func (q *Queue) Pending() []Item {
q.mu.Lock()
defer q.mu.Unlock()
@@ -228,8 +224,8 @@ func (q *Queue) Pending() []Item {
return out
}
// itemBefore meldet, ob a vor b abgearbeitet wird: höhere Klasse zuerst,
// innerhalb derselben Klasse die frühere Enqueue-Reihenfolge.
// itemBefore reports whether a is processed before b: higher class first,
// earlier enqueue order within the same class.
func itemBefore(a, b Item) bool {
if a.rank() != b.rank() {
return a.rank() > b.rank()
@@ -237,7 +233,7 @@ func itemBefore(a, b Item) bool {
return a.seq < b.seq
}
// Current liefert das gerade laufende Item (false: Queue ist idle).
// Current returns the item that is playing (false: queue is idle).
func (q *Queue) Current() (Item, bool) {
q.mu.Lock()
defer q.mu.Unlock()
@@ -247,8 +243,8 @@ func (q *Queue) Current() (Item, bool) {
return *q.current, true
}
// Cancel bricht die laufende Ansage ab und leert die Queue. Rückgabe ist
// die Zahl der abgebrochenen Ansagen (laufende + wartende).
// Cancel aborts the playing announcement and empties the queue. It returns
// the number of cancelled announcements (playing + waiting).
func (q *Queue) Cancel() int {
q.mu.Lock()
defer q.mu.Unlock()
@@ -263,9 +259,9 @@ func (q *Queue) Cancel() int {
return n
}
// Close fährt die Queue herunter: wartende Items werden verworfen, die
// laufende Ansage abgebrochen, der Worker-Loop beendet. Close wartet, bis
// der Loop sicher beendet ist (idempotent).
// Close shuts the queue down: waiting items are dropped, the playing
// announcement is aborted, the worker loop ends. Close waits until the loop
// has ended (idempotent).
func (q *Queue) Close() {
q.mu.Lock()
if q.closed {
@@ -287,10 +283,9 @@ func (q *Queue) Close() {
<-q.done
}
// LawState liefert die /status-Sicht auf die Anti-Spam-Gesetze: das
// konfigurierte Rate-Limit mit dem nächsten freien Slot (Nullzeit = frei
// oder unbegrenzt) und das Ruhezeit-Fenster mit seinem Ende, wenn es
// gerade aktiv ist.
// LawState returns the /status view of the laws: the configured rate limit
// with the next open slot (zero = open or unlimited), the quiet window with
// its end if active, and the length and age limits.
func (q *Queue) LawState() LawState {
now := time.Now()
return LawState{
@@ -306,19 +301,19 @@ func (q *Queue) LawState() LawState {
}
}
// loop ist der Worker: wartet auf fällige Items und spielt sie einzeln ab.
// „Fällig" heißt seit Task 05 auch gesetzkonform: Geparkte schlafen bis zum
// Rate-Limit-Slot (armWakeLocked weckt), low- und Ruhezeit-Verwürfe laufen
// ohne Audio durchs Protokoll, now und high-burst spielen sofort.
// Nach Close (oder wenn Close während einer Ansage zuschlägt) beendet er
// sich, sobald die laufende Ansage zurückgekehrt ist.
// loop is the worker: it waits for due items and plays them one by one.
// "Due" includes the laws: parked items sleep until the rate-limit slot
// (armWakeLocked wakes them), discards pass through the worker without
// audio but with a log line, now and high burst play immediately. After
// Close (also when Close hits during an announcement) it exits as soon as
// the playing announcement has returned.
func (q *Queue) loop() {
defer close(q.done)
for {
q.mu.Lock()
for !q.closed && !q.readyLocked() {
if len(q.items) > 0 {
// Bestes Item parkt auf den Slot: Wecker zum Termin armieren.
// The best item parks for its slot: arm the timer.
q.armWakeLocked(q.limit.Next())
}
q.notEmpty.Wait()
@@ -329,27 +324,28 @@ func (q *Queue) loop() {
}
item, v := q.takeLocked()
if v == verdictWait {
// Kurz ohne Fortschritt geweckt (z. B. verlorenes Signal):
// wieder schlafen legen. Kann nach readyLocked eigentlich nie
// eintreten — sicherheitshalber geparkt statt gedreht.
// Woken without progress (for example a lost signal): go back to
// sleep. Cannot really happen after readyLocked; parked rather
// than spun, just in case.
q.mu.Unlock()
continue
}
if v == verdictPlay {
// Fenster buchen, BEVOR gesprochen wird: Sobald das Item als
// laufend gilt, steht der nächste Slot-Termin fest.
// Book the window BEFORE speaking: as soon as the item counts as
// playing, the next slot time is fixed.
q.limit.Reserve()
ctx, cancel := context.WithCancel(context.Background())
q.current = &item
q.cancelCur = cancel
q.mu.Unlock()
q.logf("queue: ansage %s (%q, Priorität %s, now=%v) startet", item.ID, item.Text, item.Priority, item.Now)
q.logf("queue: announcement %s (%q, priority %s, now=%v) starting", item.ID, item.Text, item.Priority, item.Now)
start := time.Now()
err := q.speak(ctx, item)
dur := time.Since(start)
// Abbruch VOR dem eigenen cancel() ablesen — danach wäre ctx.Err()
// immer gesetzt und jede fertige Ansage hieße „abgebrochen".
// Read the cancellation BEFORE our own cancel(): afterwards
// ctx.Err() is always set and every finished announcement would
// read as cancelled.
cancelled := ctx.Err() != nil
cancel()
@@ -360,37 +356,37 @@ func (q *Queue) loop() {
switch {
case cancelled:
q.logf("queue: ansage %s abgebrochen", item.ID)
q.logf("queue: announcement %s cancelled", item.ID)
case err != nil:
q.logf("queue: ansage %s fehlgeschlagen nach %s: %v", item.ID, dur.Round(time.Millisecond), err)
q.logf("queue: announcement %s failed after %s: %v", item.ID, dur.Round(time.Millisecond), err)
default:
q.logf("queue: ansage %s fertig nach %s", item.ID, dur.Round(time.Millisecond))
q.logf("queue: announcement %s finished after %s", item.ID, dur.Round(time.Millisecond))
}
continue
}
q.mu.Unlock()
switch v {
case verdictDiscardRate:
q.logf("queue: ansage %s (%q, priorität low) verworfen — rate-limit %s",
q.logf("queue: announcement %s (%q, priority low) discarded: rate limit %s",
item.ID, item.Text, q.laws.RateLimit)
case verdictDiscardQuiet:
q.logf("queue: ansage %s (%q) verworfen — ruhezeit %s aktiv (modus discard)",
q.logf("queue: announcement %s (%q) discarded: quiet hours %s active (mode discard)",
item.ID, item.Text, q.laws.Quiet)
case verdictToast:
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
q.logf("queue: announcement %s (%q) replaced by toast: quiet hours %s active (mode toast)",
item.ID, item.Text, q.laws.Quiet)
q.fireToast(item)
case verdictDiscardExpired:
q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s",
q.logf("queue: announcement %s (%q) discarded: waited %s, older than max_age %s",
item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge)
}
}
}
// readyLocked meldet, ob der Worker arbeiten kann (mu muss gehalten
// werden): geschlossen immer; sonst nur, wenn das beste Item nicht bloß
// auf seinen Rate-Limit-Slot parkt. Verwürfe zählen als Arbeit — sie
// laufen ohne Audio, aber mit Protokoll durch den Worker.
// readyLocked reports whether the worker has work (mu must be held): always
// when closed; otherwise only when the best item is not merely parked for
// its rate-limit slot. Discards count as work: they pass through the worker
// without audio but with a log line.
func (q *Queue) readyLocked() bool {
if q.closed || len(q.items) == 0 {
return q.closed
@@ -398,10 +394,9 @@ func (q *Queue) readyLocked() bool {
return q.verdictLocked(q.items[q.bestIndexLocked()]) != verdictWait
}
// takeLocked entnimmt das als nächstes fällige Item, wenn es spielbar oder
// entscheidbar ist (mu muss gehalten werden). Bei verdictWait wird NICHT
// entnommen — das Item bleibt geparkt, bis der Slot frei ist (oder ein
// besseres Item vorgezogen wird).
// takeLocked removes the next due item when it is playable or decidable
// (mu must be held). On verdictWait it is NOT removed: the item stays
// parked until the slot opens (or a better item overtakes it).
func (q *Queue) takeLocked() (Item, verdict) {
best := q.bestIndexLocked()
item := q.items[best]
@@ -412,8 +407,7 @@ func (q *Queue) takeLocked() (Item, verdict) {
return item, v
}
// bestIndexLocked liefert den Index des als nächstes fälligen Items
// (mu muss gehalten werden).
// bestIndexLocked returns the index of the next due item (mu must be held).
func (q *Queue) bestIndexLocked() int {
best := 0
for i := 1; i < len(q.items); i++ {
@@ -424,16 +418,16 @@ func (q *Queue) bestIndexLocked() int {
return best
}
// verdictLocked wendet die Gesetze auf ein Item an (mu muss gehalten
// werden — die Lock-Ordnung Queue → RateLimiter bleibt gewahrt).
// verdictLocked applies the laws to an item (mu must be held; the lock order
// queue → RateLimiter is preserved).
func (q *Queue) verdictLocked(item Item) verdict {
return q.laws.decide(item, time.Now(), q.limit.Allow())
}
// armWakeLocked armiert den Slot-Wecker auf until (mu muss gehalten
// werden): Er broadcastet die Cond zum Termin, damit der geparkte Worker
// auch ohne neuen Enqueue aufwacht. Ein bereits laufender Wecker mit
// früherem (oder gleichem) Termin reicht; sonst wird er ersetzt.
// armWakeLocked arms the slot timer for until (mu must be held): it
// broadcasts the cond at that time so the parked worker wakes without a new
// Enqueue. A running timer with an earlier (or equal) time suffices;
// otherwise it is replaced.
func (q *Queue) armWakeLocked(until time.Time) {
if q.wake != nil && !q.wakeAt.After(until) {
return
@@ -454,9 +448,9 @@ func (q *Queue) armWakeLocked(until time.Time) {
})
}
// newID liefert eine kurze, kollisionsarme ID (6 Hex-Zeichen). IDs müssen
// nur pro Daemon-Lauf eindeutig sein (--wait-Polling); der Fallback ist
// praktisch unerreichbar, aber deterministisch sicher.
// newID returns a short, collision-resistant ID (6 hex characters). IDs only
// need to be unique per daemon run (--wait polling); the fallback is
// practically unreachable but deterministic.
func newID() string {
var b [3]byte
if _, err := rand.Read(b[:]); err == nil {
@@ -465,8 +459,8 @@ func newID() string {
return fmt.Sprintf("id%d", time.Now().UnixNano())
}
// logf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist. Wird nie
// unter dem Mutex gerufen (Aufrufer achten darauf).
// logf writes a log line when a log is set. Never called under the mutex
// (callers take care of that).
func (q *Queue) logf(format string, args ...any) {
if q.log != nil {
fmt.Fprintf(q.log, format+"\n", args...)
+239 -233
View File
@@ -11,23 +11,22 @@ import (
"time"
)
// gesteuerteSpielerin ist ein testbarer Speaker: Jede Ansage meldet ihren
// Start auf started (Kanal-Puffer: der Worker blockiert nie) und wartet
// dann, bis der Test sie per step freigibt — so ist die Abarbeitung
// vollständig deterministisch steuerbar. Ein abgebrochener ctx beendet das
// Warten (Cancel-/Close-Pfade). Alle Felder werden nur vom Test gelesen;
// started-Reihenfolge == Start-Reihenfolge.
type gesteuerteSpielerin struct {
// controlledSpeaker is a testable Speaker: every announcement reports its
// start on started (buffered channel: the worker never blocks) and then
// waits until the test releases it via step, so processing is fully
// deterministic. A cancelled ctx ends the wait (Cancel/Close paths). All
// fields are read only by the test; started order == start order.
type controlledSpeaker struct {
started chan Item
step chan struct{}
consumed int // von warteBis konsumierte Starts (nur Test-Goroutine)
consumed int // starts consumed by waitUntil (test goroutine only)
}
func neueSpielerin() *gesteuerteSpielerin {
return &gesteuerteSpielerin{started: make(chan Item, 16), step: make(chan struct{})}
func newSpeaker() *controlledSpeaker {
return &controlledSpeaker{started: make(chan Item, 16), step: make(chan struct{})}
}
func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
func (g *controlledSpeaker) speak(ctx context.Context, item Item) error {
g.started <- item
select {
case <-g.step:
@@ -37,9 +36,9 @@ func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
}
}
// warteBis wartet, bis insgesamt n Ansagen gestartet sind, und liefert die
// seit dem letzten warteBis-Aufruf neu gestarteten.
func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
// waitUntil waits until n announcements have started in total and returns
// the ones started since the previous waitUntil call.
func (g *controlledSpeaker) waitUntil(t *testing.T, n int) []Item {
t.Helper()
var items []Item
deadline := time.After(2 * time.Second)
@@ -49,29 +48,29 @@ func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
g.consumed++
items = append(items, it)
case <-deadline:
t.Fatalf("nur %d von %d Ansagen gestartet", g.consumed, n)
t.Fatalf("only %d of %d announcements started", g.consumed, n)
}
}
return items
}
// keineWeitere prüft, dass keine weitere Ansage startet (Poll-Fenster).
func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
// noFurther checks that no further announcement starts (poll window).
func (g *controlledSpeaker) noFurther(t *testing.T) {
t.Helper()
select {
case it := <-g.started:
g.consumed++
t.Fatalf("unerwartete Ansage %q gestartet", it.Text)
t.Fatalf("unexpected announcement %q started", it.Text)
case <-time.After(150 * time.Millisecond):
}
}
// TestAbarbeitungsReihenfolge: now vor high vor normal vor low, FIFO je
// Level. Der erste Blocker besetzt den Worker; danach werden alle Level
// gleichzeitig eingereiht — die Abarbeitung muss der Klasse folgen.
// (Ohne Gesetze: Null-Laws = unbegrenzt, keine Ruhezeit.)
func TestAbarbeitungsReihenfolge(t *testing.T) {
g := neueSpielerin()
// TestProcessingOrder: now before high before normal before low, FIFO per
// level. The first blocker occupies the worker; then all levels are
// enqueued at once and processing must follow the class.
// (No laws: zero Laws = unlimited, no quiet hours.)
func TestProcessingOrder(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
@@ -80,131 +79,130 @@ func TestAbarbeitungsReihenfolge(t *testing.T) {
t.Fatal(err)
}
if adm.ID == "" || adm.Discarded != "" {
t.Fatalf("Enqueue = %+v, want Aufnahme mit ID", adm)
t.Fatalf("Enqueue = %+v, want admission with ID", adm)
}
g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
g.waitUntil(t, 1) // blocker is playing, worker is busy
for _, it := range []Item{
{Text: "niedrig", Priority: PriorityLow},
{Text: "zweit-niedrig", Priority: PriorityLow},
{Text: "mittel", Priority: PriorityNormal},
{Text: "dringend", Priority: PriorityHigh},
{Text: "sofort", Now: true},
{Text: "low", Priority: PriorityLow},
{Text: "second-low", Priority: PriorityLow},
{Text: "normal", Priority: PriorityNormal},
{Text: "urgent", Priority: PriorityHigh},
{Text: "right-now", Now: true},
} {
if _, err := q.Enqueue(it); err != nil {
t.Fatal(err)
}
}
g.keineWeitere(t) // Serialisierung: Worker spielt noch den Blocker.
g.noFurther(t) // serialization: the worker is still on the blocker
// Freigeben: alles Übrige läuft in Klassen-Reihenfolge durch.
// Release: everything else runs through in class order.
close(g.step)
rest := g.warteBis(t, 6)
rest := g.waitUntil(t, 6)
var texts []string
for _, it := range rest {
texts = append(texts, it.Text)
}
want := []string{"sofort", "dringend", "mittel", "niedrig", "zweit-niedrig"}
want := []string{"right-now", "urgent", "normal", "low", "second-low"}
if !slices.Equal(texts, want) {
t.Errorf("Abarbeitung nach dem Blocker = %v, want %v", texts, want)
t.Errorf("processing after the blocker = %v, want %v", texts, want)
}
}
// TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
func TestCurrentUndPending(t *testing.T) {
g := neueSpielerin()
// TestCurrentAndPending: status view of the playing item and the queue.
func TestCurrentAndPending(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
if _, ok := q.Current(); ok {
t.Fatal("Current vor erster Ansage: want idle")
t.Fatal("Current before the first announcement: want idle")
}
q.Enqueue(Item{Text: "blocker"})
g.warteBis(t, 1)
g.waitUntil(t, 1)
q.Enqueue(Item{Text: "hoch", Priority: PriorityHigh})
q.Enqueue(Item{Text: "niedrig", Priority: PriorityLow})
q.Enqueue(Item{Text: "high", Priority: PriorityHigh})
q.Enqueue(Item{Text: "low", Priority: PriorityLow})
if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
t.Errorf("Current = %+v (ok=%v), will Blocker", cur, ok)
t.Errorf("Current = %+v (ok=%v), want the blocker", cur, ok)
}
var pendingTexts []string
for _, it := range q.Pending() {
pendingTexts = append(pendingTexts, it.Text)
}
if !slices.Equal(pendingTexts, []string{"hoch", "niedrig"}) {
t.Errorf("Pending = %v, want [hoch niedrig]", pendingTexts)
if !slices.Equal(pendingTexts, []string{"high", "low"}) {
t.Errorf("Pending = %v, want [high low]", pendingTexts)
}
close(g.step)
g.warteBis(t, 3)
g.waitUntil(t, 3)
}
// TestCancel: laufende Ansage wird über ctx abgebrochen, Queue geleert,
// Gesamtzahl zurückgemeldet; danach ist die Queue wieder aufnahme- und
// spielbereit.
// TestCancel: the playing announcement is aborted via ctx, the queue
// emptied, the total reported; afterwards the queue admits and plays again.
func TestCancel(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
q.Enqueue(Item{Text: "blocker"})
g.warteBis(t, 1)
g.waitUntil(t, 1)
q.Enqueue(Item{Text: "wartet-eins"})
q.Enqueue(Item{Text: "wartet-zwei"})
q.Enqueue(Item{Text: "waiting-one"})
q.Enqueue(Item{Text: "waiting-two"})
if n := q.Cancel(); n != 3 {
t.Errorf("Cancel = %d, want 3 (laufende + 2 wartende)", n)
t.Errorf("Cancel = %d, want 3 (playing + 2 waiting)", n)
}
g.keineWeitere(t) // Blocker durch ctx beendet, wartende Items sind weg.
g.noFurther(t) // blocker ended via ctx, waiting items are gone
waitIdle(t, q)
if n := q.Cancel(); n != 0 {
t.Errorf("Cancel auf leerer Queue = %d, want 0", n)
t.Errorf("Cancel on empty queue = %d, want 0", n)
}
// Queue nimmt wieder an und spielt:
adm, err := q.Enqueue(Item{Text: "danach"})
// The queue admits and plays again:
adm, err := q.Enqueue(Item{Text: "afterwards"})
if err != nil {
t.Fatal(err)
}
g.step <- struct{}{} // gibt die wartende Ansage im Speaker frei
gestartet := g.warteBis(t, 2)
if len(gestartet) != 1 || gestartet[0].ID != adm.ID {
t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
g.step <- struct{}{} // releases the waiting announcement in the speaker
started := g.waitUntil(t, 2)
if len(started) != 1 || started[0].ID != adm.ID {
t.Errorf("new announcement not started: %+v", started)
}
}
// TestClose: wartende Items werden verworfen, die laufende Ansage über den
// abgebrochenen ctx beendet, Close wartet auf den Worker-Exit, danach ist
// Enqueue ein Fehler (und Close idempotent).
// TestClose: waiting items are dropped, the playing announcement ends via
// the cancelled ctx, Close waits for the worker to exit, afterwards Enqueue
// is an error (and Close idempotent).
func TestClose(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
q.Enqueue(Item{Text: "blocker"})
g.warteBis(t, 1)
q.Enqueue(Item{Text: "wartet"})
g.waitUntil(t, 1)
q.Enqueue(Item{Text: "waiting"})
q.Close()
g.keineWeitere(t)
g.noFurther(t)
if _, ok := q.Current(); ok {
t.Error("Current nach Close: want idle")
t.Error("Current after Close: want idle")
}
if _, err := q.Enqueue(Item{Text: "zu spät"}); err == nil {
t.Error("Enqueue nach Close: want Fehler")
if _, err := q.Enqueue(Item{Text: "too late"}); err == nil {
t.Error("Enqueue after Close: want error")
}
q.Close() // idempotent
}
// TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
func TestEnqueueVergibtIDs(t *testing.T) {
g := neueSpielerin()
// TestEnqueueAssignsIDs: IDs are short and unique.
func TestEnqueueAssignsIDs(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
@@ -215,15 +213,15 @@ func TestEnqueueVergibtIDs(t *testing.T) {
t.Fatal(err)
}
if len(adm.ID) < 4 || seen[adm.ID] {
t.Errorf("ID %q zu kurz oder doppelt", adm.ID)
t.Errorf("ID %q too short or duplicate", adm.ID)
}
seen[adm.ID] = true
}
}
// waitIdle pollt, bis kein Item mehr läuft (Cancel-/Close-Pfade geben das
// nach dem Worker-Exit synchron frei; das Pollen deckt die Übergangsphase
// ab, ohne auf Timing zu trauen).
// waitIdle polls until no item is playing (Cancel/Close release that
// synchronously after the worker exits; polling covers the transition
// without trusting timing).
func waitIdle(t *testing.T, q *Queue) {
t.Helper()
deadline := time.Now().Add(time.Second)
@@ -232,314 +230,323 @@ func waitIdle(t *testing.T, q *Queue) {
return
}
if time.Now().After(deadline) {
t.Fatal("Queue wird nicht idle")
t.Fatal("queue does not become idle")
}
time.Sleep(5 * time.Millisecond)
}
}
// TestMuteStore: Datei-Roundtrip, Fehlend-Heißt-Nicht-Stumm, atomares Set.
// TestMuteStore: file round trip, missing means not muted, atomic Set.
func TestMuteStore(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, MuteFileName)
var s MuteStore
if s.Muted() {
t.Error("MuteStore ohne Pfad: Muted() = true, want false")
t.Error("MuteStore without path: Muted() = true, want false")
}
if err := s.Set(true); err == nil {
t.Error("Set ohne Pfad: want Fehler")
t.Error("Set without path: want error")
}
s = MuteStore{Path: path}
if s.Muted() {
t.Error("ohne Datei: Muted() = true, want false (nie still aus Versehen)")
t.Error("without file: Muted() = true, want false (never silent by accident)")
}
if err := s.Set(true); err != nil {
t.Fatal(err)
}
if !s.Muted() {
t.Error("nach Set(true): Muted() = false")
t.Error("after Set(true): Muted() = false")
}
if b, _ := os.ReadFile(path); string(b) != "true\n" {
t.Errorf("Datei-Inhalt = %q, want %q", b, "true\n")
t.Errorf("file content = %q, want %q", b, "true\n")
}
if err := s.Set(false); err != nil {
t.Fatal(err)
}
if s.Muted() {
t.Error("nach Set(false): Muted() = true")
t.Error("after Set(false): Muted() = true")
}
// Müll in der Datei heißt: nicht stumm (nur „true" ist stumm).
if err := os.WriteFile(path, []byte("vielleicht\n"), 0o644); err != nil {
// Garbage in the file means not muted (only "true" is muted).
if err := os.WriteFile(path, []byte("maybe\n"), 0o644); err != nil {
t.Fatal(err)
}
if s.Muted() {
t.Error("Datei-Inhalt „vielleicht“: Muted() = true, want false")
t.Error("file content \"maybe\": Muted() = true, want false")
}
}
// TestRateLimiter: Struktur-Semantik (seit Task 05 in den Loop verwired).
// TestRateLimiter: the limiter's own semantics.
func TestRateLimiter(t *testing.T) {
// Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
// Unlimited: interval 0 always allows.
unlimited := &RateLimiter{}
for i := 0; i < 3; i++ {
if !unlimited.Allow() {
t.Fatal("Interval 0: Allow = false, want true (unbegrenzt)")
t.Fatal("interval 0: Allow = false, want true (unlimited)")
}
unlimited.Reserve()
}
// Begrenzt: nach Reserve ist erst nach Interval wieder Allow.
// Limited: after Reserve, Allow only after the interval.
l := &RateLimiter{Interval: 30 * time.Millisecond}
if !l.Allow() {
t.Fatal("frisches Limit: Allow = false")
t.Fatal("fresh limiter: Allow = false")
}
l.Reserve()
if l.Allow() {
t.Fatal("sofort nach Reserve: Allow = true, want false")
t.Fatal("right after Reserve: Allow = true, want false")
}
if next := l.Next(); time.Now().After(next) {
t.Errorf("Next() = %v liegt in der Vergangenheit", next)
t.Errorf("Next() = %v lies in the past", next)
}
time.Sleep(40 * time.Millisecond)
if !l.Allow() {
t.Fatal("nach Interval: Allow = false, want true")
t.Fatal("after the interval: Allow = false, want true")
}
}
// fensterUm baut ein "HH:MM-HH:MM"-Ruhefenster, das t sicher einschließt
// (eine Minute davor bis eine Minute danach; Minute-Auflösung reicht).
func fensterUm(t time.Time) string {
return t.Add(-time.Minute).Format("15:04") + "-" + t.Add(time.Minute).Format("15:04")
// windowAround builds an "HH:MM-HH:MM" quiet window that safely contains t
// (three minutes before to four minutes after; minute resolution).
func windowAround(t time.Time) string {
return t.Add(-3*time.Minute).Format("15:04") + "-" + t.Add(4*time.Minute).Format("15:04")
}
// warteAuf verifies mit Pollen eine Bedingung (Zeit-Gesetze sind real, der
// gesteuerte Speaker allein sieht sie nicht).
func warteAuf(t *testing.T, timeout time.Duration, cond func() bool, was string) {
// waitForCond polls a condition (time laws are real; the controlled speaker
// alone cannot see them).
func waitForCond(t *testing.T, timeout time.Duration, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(timeout)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("%s innerhalb %s nicht eingetreten", was, timeout)
t.Fatalf("%s did not happen within %s", what, timeout)
}
time.Sleep(5 * time.Millisecond)
}
}
// TestRateLimitLowVerworfenNormalParkt: mit kurzem Fenster spielt die
// erste Ansage sofort; low über dem Budget wird schon beim Eintritt
// verworfen (Admission nennt den Grund), normal parkt und spielt nach
// Fenster-Ablauf.
func TestRateLimitLowVerworfenNormalParkt(t *testing.T) {
g := neueSpielerin()
// TestRateLimitLowDiscardedNormalParks: with a short window the first
// announcement plays at once; low over budget is discarded on admission
// (Admission names the reason), normal parks and plays after the window.
func TestRateLimitLowDiscardedNormalParks(t *testing.T) {
g := newSpeaker()
q := New(Laws{RateLimit: 400 * time.Millisecond}, g.speak, nil)
defer q.Close()
if adm, err := q.Enqueue(Item{Text: "erste"}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("erste Enqueue = %+v (%v), want Aufnahme", adm, err)
if adm, err := q.Enqueue(Item{Text: "first"}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("first Enqueue = %+v (%v), want admission", adm, err)
}
g.warteBis(t, 1) // erste läuft — Fenster ist gebucht.
g.waitUntil(t, 1) // first is playing, the window is booked
adm, err := q.Enqueue(Item{Text: "low-eins", Priority: PriorityLow})
adm, err := q.Enqueue(Item{Text: "low-one", Priority: PriorityLow})
if err != nil {
t.Fatal(err)
}
if adm.ID != "" || adm.Discarded != DiscardRateLimit || adm.Toast {
t.Errorf("low über Budget = %+v, want Verwurf %q", adm, DiscardRateLimit)
t.Errorf("low over budget = %+v, want discard %q", adm, DiscardRateLimit)
}
if adm, err = q.Enqueue(Item{Text: "low-zwei", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
t.Errorf("zweiter low-Verwurf = %+v (%v)", adm, err)
if adm, err = q.Enqueue(Item{Text: "low-two", Priority: PriorityLow}); err != nil || adm.Discarded != DiscardRateLimit {
t.Errorf("second low discard = %+v (%v)", adm, err)
}
adm, err = q.Enqueue(Item{Text: "zweite"})
adm, err = q.Enqueue(Item{Text: "second"})
if err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("normal = %+v (%v), want Aufnahme (parkt bis zum Slot)", adm, err)
t.Fatalf("normal = %+v (%v), want admission (parks until the slot)", adm, err)
}
// Status-Sicht während der erste läuft: zweite wartet (normal, geparkt).
warteAuf(t, time.Second, func() bool {
// Status view while the first plays: second waits (normal, parked).
waitForCond(t, time.Second, func() bool {
for _, it := range q.Pending() {
if it.Text == "zweite" {
if it.Text == "second" {
return true
}
}
return false
}, "geparkte normale Ansage in Pending")
}, "parked normal announcement in Pending")
close(g.step) // erste beenden — zweite muss weiter parken …
g.keineWeitere(t)
// … und nach Fenster-Ablauf starten (Deadline deckt Jitter locker).
gestartet := g.warteBis(t, 2)
if len(gestartet) != 1 || gestartet[0].Text != "zweite" {
t.Errorf("nach Fenster-Ablauf = %+v, will zweite", gestartet)
close(g.step) // end the first; second must keep parking …
g.noFurther(t)
// … and start after the window elapses (deadline covers jitter easily).
started := g.waitUntil(t, 2)
if len(started) != 1 || started[0].Text != "second" {
t.Errorf("after the window = %+v, want second", started)
}
// low-eins/low-zwei spielen nie: genau 2 Starts insgesamt.
g.keineWeitere(t)
// low-one/low-two never play: exactly 2 starts in total.
g.noFurther(t)
}
// TestHighBurstEinUndAus: mit Burst-Recht startet high sofort nach der
// laufenden Ansage, obwohl das Fenster voll ist; ohne Recht parkt high
// wie normal (kein Verstoß).
func TestHighBurstEinUndAus(t *testing.T) {
g := neueSpielerin()
// TestHighBurstOnAndOff: with burst rights high starts right after the
// playing announcement although the window is full; without rights high
// parks like normal (no violation).
func TestHighBurstOnAndOff(t *testing.T) {
g := newSpeaker()
q := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: true}, g.speak, nil)
q.Enqueue(Item{Text: "erste"})
g.warteBis(t, 1)
if adm, err := q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high mit Burst-Recht = %+v (%v), want Aufnahme", adm, err)
q.Enqueue(Item{Text: "first"})
g.waitUntil(t, 1)
if adm, err := q.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high with burst rights = %+v (%v), want admission", adm, err)
}
g.step <- struct{}{} // erste beenden
gestartet := g.warteBis(t, 2) // high startet trotz vollem Fenster
if len(gestartet) != 1 || gestartet[0].Text != "dringend" {
t.Errorf("burst = %+v, will dringend sofort", gestartet)
g.step <- struct{}{} // end the first
started := g.waitUntil(t, 2) // high starts despite the full window
if len(started) != 1 || started[0].Text != "urgent" {
t.Errorf("burst = %+v, want urgent right away", started)
}
q.Close()
g2 := neueSpielerin()
g2 := newSpeaker()
q2 := New(Laws{RateLimit: 10 * time.Second, AllowHighBurst: false}, g2.speak, nil)
defer q2.Close()
q2.Enqueue(Item{Text: "erste"})
g2.warteBis(t, 1) // erste läuft — Fenster gebucht
if adm, err := q2.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high ohne Burst-Recht = %+v (%v), want Aufnahme (parkt)", adm, err)
q2.Enqueue(Item{Text: "first"})
g2.waitUntil(t, 1) // first is playing, window booked
if adm, err := q2.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.ID == "" {
t.Fatalf("high without burst rights = %+v (%v), want admission (parks)", adm, err)
}
g2.step <- struct{}{} // erste beenden
g2.keineWeitere(t) // high wartet den Slot ab — kein Verstoß ohne Recht
g2.step <- struct{}{} // end the first
g2.noFurther(t) // high waits for the slot: no violation without rights
}
// TestNowBypassesRateLimit: now startet trotz vollem Fensters sofort —
// der dokumentierte Ausbruch (er startet trotzdem ein neues Fenster).
// TestNowBypassesRateLimit: now starts at once despite a full window, the
// documented escape (it still books a new window).
func TestNowBypassesRateLimit(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{RateLimit: 10 * time.Second}, g.speak, nil)
defer q.Close()
q.Enqueue(Item{Text: "erste"})
g.warteBis(t, 1) // Fenster gebucht (10 s)
q.Enqueue(Item{Text: "first"})
g.waitUntil(t, 1) // window booked (10 s)
if adm, err := q.Enqueue(Item{Text: "sofort", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("now über vollem Fenster = %+v (%v), want Aufnahme", adm, err)
if adm, err := q.Enqueue(Item{Text: "right-now", Now: true}); err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("now over a full window = %+v (%v), want admission", adm, err)
}
g.step <- struct{}{}
gestartet := g.warteBis(t, 2) // lange vor Fenster-Ablauf (10 s) — sonst Timeout
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
t.Errorf("now = %+v, will sofort", gestartet)
started := g.waitUntil(t, 2) // long before the window elapses (10 s), else timeout
if len(started) != 1 || started[0].Text != "right-now" {
t.Errorf("now = %+v, want right-now", started)
}
// Der now-Bruch hat ein neues Fenster gebucht: normal parkt jetzt.
if adm, err := q.Enqueue(Item{Text: "danach"}); err != nil || adm.ID == "" {
t.Fatalf("normal nach now = %+v (%v)", adm, err)
// The now break-out booked a new window: normal parks now.
if adm, err := q.Enqueue(Item{Text: "afterwards"}); err != nil || adm.ID == "" {
t.Fatalf("normal after now = %+v (%v)", adm, err)
}
close(g.step)
g.keineWeitere(t) // parkt bis zum Slot — nicht sofort
g.noFurther(t) // parks until the slot, not immediately
}
// TestRuhezeitDiscardUndToast: aktives Fenster verwirft normal und high
// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — toast ersetzt stattdessen;
// now bricht bewusst aus.
func TestRuhezeitDiscardUndToast(t *testing.T) {
fenster := fensterUm(time.Now())
// TestQuietHoursDiscardAndToast: an active window discards normal and high
// (burst is a rate right, not a quiet right); toast replaces instead; now
// deliberately breaks out.
func TestQuietHoursDiscardAndToast(t *testing.T) {
window := windowAround(time.Now())
g := neueSpielerin()
q := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeDiscard}, g.speak, nil)
g := newSpeaker()
q := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeDiscard}, g.speak, nil)
defer q.Close()
adm, err := q.Enqueue(Item{Text: "normal"})
if err != nil || adm.ID != "" || adm.Discarded != DiscardQuietHours || adm.Toast {
t.Errorf("normal in Ruhezeit = %+v (%v), want stiller Verwurf", adm, err)
t.Errorf("normal in quiet hours = %+v (%v), want silent discard", adm, err)
}
if adm, err = q.Enqueue(Item{Text: "dringend", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
t.Errorf("high in Ruhezeit = %+v (%v), want auch Verwurf", adm, err)
if adm, err = q.Enqueue(Item{Text: "urgent", Priority: PriorityHigh}); err != nil || adm.Discarded != DiscardQuietHours {
t.Errorf("high in quiet hours = %+v (%v), want discard too", adm, err)
}
adm, err = q.Enqueue(Item{Text: "sofort", Now: true})
adm, err = q.Enqueue(Item{Text: "right-now", Now: true})
if err != nil || adm.ID == "" || adm.Discarded != "" {
t.Fatalf("now in Ruhezeit = %+v (%v), want Ausbruch", adm, err)
t.Fatalf("now in quiet hours = %+v (%v), want break-out", adm, err)
}
gestartet := g.warteBis(t, 1)
if len(gestartet) != 1 || gestartet[0].Text != "sofort" {
t.Errorf("in Ruhezeit = %+v, will nur sofort", gestartet)
started := g.waitUntil(t, 1)
if len(started) != 1 || started[0].Text != "right-now" {
t.Errorf("in quiet hours = %+v, want only right-now", started)
}
g.keineWeitere(t)
g.noFurther(t)
g2 := neueSpielerin()
q2 := New(Laws{Quiet: mustQuiet(t, fenster), QuietMode: QuietModeToast}, g2.speak, nil)
g2 := newSpeaker()
q2 := New(Laws{Quiet: mustQuiet(t, window), QuietMode: QuietModeToast}, g2.speak, nil)
defer q2.Close()
toasted := make(chan Item, 1)
q2.OnToast(func(it Item) { toasted <- it })
adm, err = q2.Enqueue(Item{Text: "normal"})
if err != nil || adm.Discarded != DiscardQuietHours || !adm.Toast {
t.Errorf("normal in Ruhezeit (toast) = %+v (%v), want Toast-Ersetzung", adm, err)
t.Errorf("normal in quiet hours (toast) = %+v (%v), want toast replacement", adm, err)
}
g2.keineWeitere(t)
select {
case it := <-toasted:
if it.Text != "normal" {
t.Errorf("toast hook got %q", it.Text)
}
case <-time.After(time.Second):
t.Error("toast hook not fired")
}
g2.noFurther(t)
}
// mustQuiet parst ein Ruhefenster oder bricht den Test ab.
func mustQuiet(t *testing.T, fenster string) QuietHours {
// mustQuiet parses a quiet window or fails the test.
func mustQuiet(t *testing.T, window string) QuietHours {
t.Helper()
q, err := ParseQuietHours(fenster)
q, err := ParseQuietHours(window)
if err != nil {
t.Fatal(err)
}
return q
}
// TestLawState: unbegrenzt liefert freien Slot; nach gebuchtem Fenster
// liegt der nächste Slot in der Zukunft; Ruhezeit-Fenster ist sichtbar.
// TestLawState: unlimited yields an open slot; after a booked window the
// next slot lies in the future; the quiet window and limits are visible.
func TestLawState(t *testing.T) {
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
st := q.LawState()
if !st.NextSlot.IsZero() || st.RateLimit != 0 {
t.Errorf("unbegrenzt = %+v, want Nullzeit-Slot", st)
t.Errorf("unlimited = %+v, want zero-time slot", st)
}
q.Close()
// Das Ruhezeit-Fenster muss JETZT inaktiv sein, sonst würde die Ansage
// unten getoastet statt gesprochen — ein festes Fenster wäre eine
// Zeitbombe (rot zu bestimmten Tageszeiten). Deshalb dynamisch: eine
// Stunde, die zwei Stunden in der Zukunft beginnt.
// The quiet window must be inactive NOW, otherwise the announcement
// below would be toasted instead of spoken. A fixed window would be a
// time bomb (red at certain hours), hence dynamic: one hour starting two
// hours from now.
window := inactiveQuietWindow(time.Now())
fenster := mustQuiet(t, window)
if fenster.Active(time.Now()) {
t.Fatalf("Testfenster %s ist aktiv — darf nicht sein", window)
quiet := mustQuiet(t, window)
if quiet.Active(time.Now()) {
t.Fatalf("test window %s is active, must not be", window)
}
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: fenster, QuietMode: QuietModeToast}, g.speak, nil)
q2 := New(Laws{RateLimit: time.Hour, AllowHighBurst: true, Quiet: quiet, QuietMode: QuietModeToast, MaxText: 400, MaxAge: 2 * time.Minute}, g.speak, nil)
defer q2.Close()
st2 := q2.LawState()
if st2.RateLimit != time.Hour || !st2.AllowHighBurst {
t.Errorf("LawState = %+v, want 1h-Fenster mit Burst-Recht", st2)
if st2.RateLimit != time.Hour || !st2.AllowHighBurst || st2.MaxText != 400 || st2.MaxAge != 2*time.Minute {
t.Errorf("LawState = %+v, want 1h window with burst rights and limits 400/2m", st2)
}
if st2.Quiet.String() != window || st2.QuietMode != QuietModeToast {
t.Errorf("Ruhezeit in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
t.Errorf("quiet hours in LawState = %q/%q, want %q/toast", st2.Quiet, st2.QuietMode, window)
}
if st2.QuietActive || !st2.QuietUntil.IsZero() {
t.Errorf("Ruhezeit inaktiv erwartet, LawState meldet active=%v until=%v", st2.QuietActive, st2.QuietUntil)
t.Errorf("quiet hours expected inactive, LawState reports active=%v until=%v", st2.QuietActive, st2.QuietUntil)
}
q2.Enqueue(Item{Text: "erste"})
g.warteBis(t, 1)
q2.Enqueue(Item{Text: "first"})
g.waitUntil(t, 1)
if next := q2.LawState().NextSlot; !next.After(time.Now()) {
t.Errorf("nach Start = %v, want Slot in der Zukunft", next)
t.Errorf("after start = %v, want slot in the future", next)
}
g.step <- struct{}{}
}
// inactiveQuietWindow liefert ein "HH:MM-HH:MM"-Fenster von einer Stunde,
// das zwei Stunden nach now beginnt — es enthält now garantiert nicht, egal
// zu welcher Tageszeit der Test läuft (auch über Mitternacht hinweg).
// inactiveQuietWindow returns a one-hour "HH:MM-HH:MM" window starting two
// hours after now; it never contains now, whatever the time of day (also
// across midnight).
func inactiveQuietWindow(now time.Time) string {
start := now.Add(2 * time.Hour)
end := now.Add(3 * time.Hour)
return start.Format("15:04") + "-" + end.Format("15:04")
}
// TestInactiveQuietWindow: Das Test-Hilfsfenster darf zu KEINER Tageszeit
// aktiv sein — sonst wäre TestLawState wieder eine Zeitbombe.
// TestInactiveQuietWindow: the helper window must not be active at ANY time
// of day, otherwise TestLawState would be a time bomb again.
func TestInactiveQuietWindow(t *testing.T) {
base := time.Date(2026, 10, 24, 0, 0, 0, 0, time.Local)
for m := 0; m < 24*60; m += 7 {
@@ -550,12 +557,12 @@ func TestInactiveQuietWindow(t *testing.T) {
t.Fatalf("%s: %v", now.Format("15:04"), err)
}
if q.Active(now) {
t.Errorf("um %s ist Fenster %s aktiv", now.Format("15:04"), w)
t.Errorf("at %s the window %s is active", now.Format("15:04"), w)
}
}
}
// syncLog ist ein nebenläufigkeitssicherer Log-Puffer für Worker-Zeilen.
// syncLog is a concurrency-safe log buffer for worker lines.
type syncLog struct {
mu sync.Mutex
b strings.Builder
@@ -573,37 +580,36 @@ func (l *syncLog) String() string {
return l.b.String()
}
// TestProtokollFertigNichtAbgebrochen: Eine normal beendete Ansage wird als
// „fertig" protokolliert, nicht als „abgebrochen" — der Worker darf seinen
// eigenen cancel() nicht als Abbruch deuten. Ein echter Cancel heißt
// dagegen „abgebrochen".
func TestProtokollFertigNichtAbgebrochen(t *testing.T) {
// TestLogFinishedNotCancelled: a normally finished announcement is logged as
// "finished", not "cancelled"; the worker must not read its own cancel() as
// a cancellation. A real Cancel does log "cancelled".
func TestLogFinishedNotCancelled(t *testing.T) {
logb := &syncLog{}
g := neueSpielerin()
g := newSpeaker()
q := New(Laws{}, g.speak, logb)
defer q.Close()
q.Enqueue(Item{Text: "normal zu ende"})
g.warteBis(t, 1)
q.Enqueue(Item{Text: "runs to the end"})
g.waitUntil(t, 1)
g.step <- struct{}{}
waitFor(t, func() bool { return strings.Contains(logb.String(), "fertig nach") })
if strings.Contains(logb.String(), "abgebrochen") {
t.Errorf("fertige Ansage als abgebrochen protokolliert:\n%s", logb.String())
waitFor(t, func() bool { return strings.Contains(logb.String(), "finished after") })
if strings.Contains(logb.String(), "cancelled") {
t.Errorf("finished announcement logged as cancelled:\n%s", logb.String())
}
q.Enqueue(Item{Text: "wird abgebrochen"})
g.warteBis(t, 2)
q.Enqueue(Item{Text: "gets cancelled"})
g.waitUntil(t, 2)
q.Cancel()
waitFor(t, func() bool { return strings.Contains(logb.String(), "abgebrochen") })
waitFor(t, func() bool { return strings.Contains(logb.String(), "cancelled") })
}
// waitFor pollt cond bis zu zwei Sekunden.
// waitFor polls cond for up to two seconds.
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("Bedingung nicht binnen 2 s erfüllt")
t.Fatal("condition not met within 2 s")
}
time.Sleep(5 * time.Millisecond)
}
+24 -24
View File
@@ -7,29 +7,29 @@ import (
"time"
)
// Erlaubte Werte für quiet_mode (öffentlicher Config-Wert).
// Allowed values for quiet_mode (public config value).
const (
QuietModeDiscard = "discard"
QuietModeToast = "toast"
)
// clockTime ist eine Tageszeit in Minuten-Auflösung (lokale Zeit, 24 h).
// clockTime is a time of day at minute resolution (local time, 24 h).
type clockTime struct{ hour, minute int }
// minutes liefert Minuten seit Mitternacht.
// minutes returns minutes since midnight.
func (c clockTime) minutes() int { return c.hour*60 + c.minute }
// String rendert die Uhrzeit als "HH:MM".
// String renders the time as "HH:MM".
func (c clockTime) String() string { return fmt.Sprintf("%02d:%02d", c.hour, c.minute) }
// QuietHours ist ein Ruhezeit-Fenster in lokaler Zeit, z. B. "18:00-08:00"
// (überspannt Mitternacht). Der Nullwert (Start == End) ist deaktiviert —
// rund-um-die-Uhr-Stumm ist mute, nicht quiet_hours.
// QuietHours is a quiet window in local time, for example "18:00-08:00"
// (spanning midnight). The zero value (Start == End) is disabled; silence
// around the clock is mute, not quiet hours.
type QuietHours struct {
Start, End clockTime
}
// String rendert das Fenster als "HH:MM-HH:MM" (Nullwert: "").
// String renders the window as "HH:MM-HH:MM" (zero value: "").
func (q QuietHours) String() string {
if q.Start == q.End {
return ""
@@ -37,17 +37,17 @@ func (q QuietHours) String() string {
return q.Start.String() + "-" + q.End.String()
}
// ParseQuietHours parst ein Ruhezeit-Fenster "HH:MM-HH:MM" (lokale Zeit,
// 24 h; Stunden ein- oder zweistellig). Fenster über Mitternacht
// (Start > End) sind erlaubt; Start == End deaktiviert das Fenster. Der
// Leerstring ist das deaktivierte Fenster (kein Fehler).
// ParseQuietHours parses a quiet window "HH:MM-HH:MM" (local time, 24 h;
// hours with one or two digits). Windows across midnight (Start > End) are
// allowed; Start == End disables the window. The empty string is the
// disabled window (no error).
func ParseQuietHours(s string) (QuietHours, error) {
if s == "" {
return QuietHours{}, nil
}
startS, endS, ok := strings.Cut(s, "-")
if !ok {
return QuietHours{}, fmt.Errorf("quiet_hours: %q ist nicht HH:MM-HH:MM", s)
return QuietHours{}, fmt.Errorf("quiet_hours: %q is not HH:MM-HH:MM", s)
}
start, err := parseClock(startS)
if err != nil {
@@ -60,29 +60,29 @@ func ParseQuietHours(s string) (QuietHours, error) {
return QuietHours{Start: start, End: end}, nil
}
// parseClock parst "H[H]:MM" in Uhrzeit-Bestandteile.
// parseClock parses "H[H]:MM" into its parts.
func parseClock(s string) (clockTime, error) {
hourS, minuteS, ok := strings.Cut(s, ":")
if !ok || len(hourS) < 1 || len(hourS) > 2 || len(minuteS) != 2 {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
hour, err := strconv.Atoi(hourS)
if err != nil {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
minute, err := strconv.Atoi(minuteS)
if err != nil {
return clockTime{}, fmt.Errorf("%q ist nicht HH:MM", s)
return clockTime{}, fmt.Errorf("%q is not HH:MM", s)
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return clockTime{}, fmt.Errorf("%q ist keine gültige Uhrzeit (24 h)", s)
return clockTime{}, fmt.Errorf("%q is not a valid time of day (24 h)", s)
}
return clockTime{hour: hour, minute: minute}, nil
}
// Active meldet, ob der lokale Zeitpunkt t im Ruhefenster liegt.
// Tagfenster (Start < End) gelten ab Start bis ausschließlich End;
// Nachtfenster (Start > End) überspannen Mitternacht.
// Active reports whether the local time t lies inside the window. Day
// windows (Start < End) run from Start up to but excluding End; night
// windows (Start > End) span midnight.
func (q QuietHours) Active(t time.Time) bool {
if q.Start == q.End {
return false
@@ -95,15 +95,15 @@ func (q QuietHours) Active(t time.Time) bool {
return m >= s || m < e
}
// Until liefert das lokale Ende des gerade aktiven Fensters — Nullzeit,
// wenn gerade kein Ruhefenster aktiv ist. Basis für GET /status.
// Until returns the local end of the currently active window; the zero time
// when no window is active. Basis for GET /status.
func (q QuietHours) Until(t time.Time) time.Time {
if !q.Active(t) {
return time.Time{}
}
end := time.Date(t.Year(), t.Month(), t.Day(), q.End.hour, q.End.minute, 0, 0, t.Location())
if q.Start.minutes() > q.End.minutes() && t.Hour()*60+t.Minute() >= q.Start.minutes() {
end = end.AddDate(0, 0, 1) // Nachtfenster: das Ende ist am Folgetag
end = end.AddDate(0, 0, 1) // night window: the end is tomorrow
}
return end
}
+41 -40
View File
@@ -5,14 +5,14 @@ import (
"time"
)
// lokal baut eine lokale Test-Zeit (Stunde/Minute reichen — Ruhefenster
// arbeiten auf Minuten-Auflösung).
func lokal(hour, minute int) time.Time {
// local builds a local test time (hour and minute suffice; quiet windows
// work at minute resolution).
func local(hour, minute int) time.Time {
return time.Date(2026, 10, 18, hour, minute, 0, 0, time.Local)
}
// TestParseQuietHours: Format HH:MM-HH:MM (überspannt Mitternacht),
// klare Fehler für Müll.
// TestParseQuietHours: format HH:MM-HH:MM (may span midnight), clear errors
// for garbage.
func TestParseQuietHours(t *testing.T) {
cases := []struct {
in string
@@ -23,8 +23,8 @@ func TestParseQuietHours(t *testing.T) {
{in: "18:00-08:00", start: clockTime{18, 0}, end: clockTime{8, 0}},
{in: "9:30-17:45", start: clockTime{9, 30}, end: clockTime{17, 45}},
{in: "22:00-06:00", start: clockTime{22, 0}, end: clockTime{6, 0}},
{in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // deaktiviert, aber gültig
{in: ""}, // Leerstring = deaktiviert, kein Fehler
{in: "08:00-08:00", start: clockTime{8, 0}, end: clockTime{8, 0}}, // disabled but valid
{in: ""}, // empty = disabled, no error
{in: "18:00", bad: true},
{in: "18:00-", bad: true},
{in: "-08:00", bad: true},
@@ -37,12 +37,12 @@ func TestParseQuietHours(t *testing.T) {
q, err := ParseQuietHours(tc.in)
if tc.bad {
if err == nil {
t.Errorf("ParseQuietHours(%q) = %+v, want Fehler", tc.in, q)
t.Errorf("ParseQuietHours(%q) = %+v, want error", tc.in, q)
}
continue
}
if err != nil {
t.Errorf("ParseQuietHours(%q): unerwarteter Fehler %v", tc.in, err)
t.Errorf("ParseQuietHours(%q): unexpected error %v", tc.in, err)
continue
}
if q.Start != tc.start || q.End != tc.end {
@@ -51,69 +51,70 @@ func TestParseQuietHours(t *testing.T) {
}
}
// TestQuietHoursActive: Tagfenster, Nachtfenster über Mitternacht und der
// deaktivierte Nullwert über alle Grenzen (Start inklusiv, End exklusiv).
// TestQuietHoursActive: day window, night window across midnight and the
// disabled zero value across all boundaries (start inclusive, end
// exclusive).
func TestQuietHoursActive(t *testing.T) {
cases := []struct {
fenster string
window string
hour, minute int
want bool
}{
// Tagfenster 09:00-17:00
// day window 09:00-17:00
{"09:00-17:00", 8, 59, false},
{"09:00-17:00", 9, 0, true}, // Start inklusiv
{"09:00-17:00", 9, 0, true}, // start inclusive
{"09:00-17:00", 12, 30, true},
{"09:00-17:00", 16, 59, true},
{"09:00-17:00", 17, 0, false}, // End exklusiv
{"09:00-17:00", 17, 0, false}, // end exclusive
{"09:00-17:00", 23, 0, false},
// Nachtfenster 18:00-08:00 (überspannt Mitternacht)
// night window 18:00-08:00 (spans midnight)
{"18:00-08:00", 17, 59, false},
{"18:00-08:00", 18, 0, true},
{"18:00-08:00", 23, 59, true},
{"18:00-08:00", 0, 0, true}, // über Mitternacht
{"18:00-08:00", 0, 0, true}, // past midnight
{"18:00-08:00", 7, 59, true},
{"18:00-08:00", 8, 0, false}, // End exklusiv
{"18:00-08:00", 8, 0, false}, // end exclusive
{"18:00-08:00", 12, 0, false},
// Zweites Nachtfenster
// another night window
{"22:00-06:00", 3, 0, true},
{"22:00-06:00", 21, 59, false},
// Start == End: deaktiviert
// start == end: disabled
{"08:00-08:00", 8, 0, false},
{"08:00-08:00", 20, 0, false},
}
for _, tc := range cases {
q := mustQuiet(t, tc.fenster)
if got := q.Active(lokal(tc.hour, tc.minute)); got != tc.want {
t.Errorf("%s um %02d:%02d: Active = %v, want %v", tc.fenster, tc.hour, tc.minute, got, tc.want)
q := mustQuiet(t, tc.window)
if got := q.Active(local(tc.hour, tc.minute)); got != tc.want {
t.Errorf("%s at %02d:%02d: Active = %v, want %v", tc.window, tc.hour, tc.minute, got, tc.want)
}
}
}
// TestQuietHoursUntil: Fenster-Ende (heute bzw. morgen bei Nachtfenstern),
// Nullzeit wenn inaktiv.
// TestQuietHoursUntil: window end (today, or tomorrow for night windows),
// zero time when inactive.
func TestQuietHoursUntil(t *testing.T) {
// Tagfenster: Ende heute.
// Day window: ends today.
q := mustQuiet(t, "09:00-17:00")
if want := lokal(17, 0); !q.Until(lokal(12, 0)).Equal(want) {
t.Errorf("Until(12:00) = %v, want %v (heute 17:00)", q.Until(lokal(12, 0)), want)
if want := local(17, 0); !q.Until(local(12, 0)).Equal(want) {
t.Errorf("Until(12:00) = %v, want %v (today 17:00)", q.Until(local(12, 0)), want)
}
if !q.Until(lokal(3, 0)).IsZero() {
t.Error("Until außerhalb des Fensters: want Nullzeit")
if !q.Until(local(3, 0)).IsZero() {
t.Error("Until outside the window: want zero time")
}
// Nachtfenster: ab 18:00 ist das Ende morgen früh.
// Night window: from 18:00 the end is tomorrow morning.
n := mustQuiet(t, "18:00-08:00")
if want := lokal(0, 0).Add(32 * time.Hour); !n.Until(lokal(20, 0)).Equal(want) {
t.Errorf("Until(20:00) = %v, want %v (morgen 08:00)", n.Until(lokal(20, 0)), want)
if want := local(0, 0).Add(32 * time.Hour); !n.Until(local(20, 0)).Equal(want) {
t.Errorf("Until(20:00) = %v, want %v (tomorrow 08:00)", n.Until(local(20, 0)), want)
}
// Nach Mitternacht (00:30) ist das Ende heute 08:00.
if want := lokal(8, 0); !n.Until(lokal(0, 30)).Equal(want) {
t.Errorf("Until(00:30) = %v, want %v (heute 08:00)", n.Until(lokal(0, 30)), want)
// Past midnight (00:30) the end is today 08:00.
if want := local(8, 0); !n.Until(local(0, 30)).Equal(want) {
t.Errorf("Until(00:30) = %v, want %v (today 08:00)", n.Until(local(0, 30)), want)
}
}
// TestQuietHoursStringRoundtrip: String liefert die konfigurierte Form
// zurück und parst identisch; der Nullwert rendert leer.
// TestQuietHoursStringRoundtrip: String returns the configured form and
// parses back identically; the zero value renders empty.
func TestQuietHoursStringRoundtrip(t *testing.T) {
for _, s := range []string{"18:00-08:00", "09:00-17:00", "22:00-06:00"} {
q := mustQuiet(t, s)
@@ -122,10 +123,10 @@ func TestQuietHoursStringRoundtrip(t *testing.T) {
}
q2 := mustQuiet(t, q.String())
if q2 != q {
t.Errorf("Roundtrip von %q: %+v ≠ %+v", s, q2, q)
t.Errorf("round trip of %q: %+v != %+v", s, q2, q)
}
}
if (QuietHours{}).String() != "" {
t.Error("Nullwert-String nicht leer")
t.Error("zero value String is not empty")
}
}
+79 -82
View File
@@ -7,32 +7,32 @@ import (
"unicode"
)
// Verwurfs-Gründe im HTTP-Vertrag von POST /speak
// Discard reasons in the HTTP contract of POST /speak
// (202 {"id":"","discarded":"…"}).
const (
DiscardRateLimit = "rate_limit" // priority=low über dem Fenster-Budget
DiscardQuietHours = "quiet_hours" // Ruhezeit aktiv (discard oder toast)
DiscardExpired = "expired" // länger gewartet als queue.max_age (Ansage wäre veraltet)
DiscardRateLimit = "rate_limit" // priority=low over the window budget
DiscardQuietHours = "quiet_hours" // quiet hours active (discard or toast)
DiscardExpired = "expired" // waited longer than queue.max_age (stale)
)
// RateLimiter ist das Zeitfenster des Daemon-Gesetzes gegen Ansage-Spam:
// Spec-Default ca. 1 Ansage / 30 s (queue.rate_limit), 0 = unbegrenzt.
// Jede gestartete Ansage bucht das Fenster neu (Reserve) — auch eine per
// now oder high-burst ausgebrochene: Das Fenster zählt Abspiel-Starts,
// nicht deren Legitimität.
// RateLimiter is the time window of the daemon's anti-spam law: default
// about one announcement per 30 s (queue.rate_limit), 0 = unlimited. Every
// started announcement books the window anew (Reserve), including ones that
// broke out via now or high burst: the window counts playback starts, not
// their legitimacy.
//
// Lock-Ordnung: RateLimiter.mu ist immer die innere Sperre — Aufrufer
// halten beim Allow/Reserve/Next die Queue-sperre (niemals umgekehrt).
// Lock order: RateLimiter.mu is always the inner lock; callers hold the
// queue lock during Allow/Reserve/Next, never the other way round.
type RateLimiter struct {
// Interval ist der Mindestabstand zwischen zwei Ansage-Starten.
// 0 oder negativ = unbegrenzt.
// Interval is the minimum gap between two announcement starts. 0 or
// negative = unlimited.
Interval time.Duration
mu sync.Mutex
last time.Time // Start der zuletzt per Reserve gebuchten Ansage
last time.Time // start of the announcement last booked via Reserve
}
// Allow meldet, ob sofort eine Ansage starten dürfte.
// Allow reports whether an announcement could start right now.
func (l *RateLimiter) Allow() bool {
if l == nil || l.Interval <= 0 {
return true
@@ -42,10 +42,9 @@ func (l *RateLimiter) Allow() bool {
return time.Since(l.last) >= l.Interval
}
// Reserve bucht einen Ansage-Slot (direkt nach der Play-Entscheidung
// aufrufen, vor dem eigentlichen Abspielen — auch fehlgeschlagene Ansagen
// verbrauchen das Fenster: Ein kaputter Provider darf nicht zum Maschinengewehr
// werden).
// Reserve books a slot. Call it right after the play decision, before actual
// playback: failed announcements consume the window too, so a broken
// provider cannot turn into a machine gun.
func (l *RateLimiter) Reserve() {
if l == nil || l.Interval <= 0 {
return
@@ -55,9 +54,9 @@ func (l *RateLimiter) Reserve() {
l.last = time.Now()
}
// Next liefert, wann der nächste Ansage-Slot frei wird — Nullzeit, wenn
// Allow sofort true liefern würde (unbegrenzt oder Fenster abgelaufen).
// Basis für das Parken wartender Items und den Status-Endpoint.
// Next returns when the next slot opens; the zero time when Allow would
// return true right away (unlimited or window elapsed). Basis for parking
// waiting items and for the status endpoint.
func (l *RateLimiter) Next() time.Time {
if l == nil || l.Interval <= 0 {
return time.Time{}
@@ -70,68 +69,65 @@ func (l *RateLimiter) Next() time.Time {
return l.last.Add(l.Interval)
}
// Laws sind die Anti-Spam-Gesetze des Daemons (Task 05): Rate-Limit mit
// verkonfigurierbarem Verstoß-Recht für high, plus Ruhezeiten. Der Nullwert
// erlaubt alles (unbegrenzt, kein Ruhefenster) — der Daemon liefert die
// Gesetze aus der Config; Default siehe internal/config.Defaults.
// Laws are the daemon's anti-spam laws: rate limit with configurable burst
// rights for high, quiet hours, text length cap and maximum queue age. The
// zero value allows everything; the daemon supplies the laws from the
// config (defaults in internal/config.Defaults).
type Laws struct {
// RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten
// (0 = unbegrenzt).
// RateLimit is the minimum gap between two announcement starts
// (0 = unlimited).
RateLimit time.Duration
// AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu
// verstoßen (Default true). Ohne Burst-Recht wartet high wie normal.
// Die Ruhezeit gilt davon unberührt auch für high.
// AllowHighBurst lets priority=high deliberately break the rate limit
// (default true). Without burst rights, high waits like normal. Quiet
// hours apply to high regardless.
AllowHighBurst bool
// Quiet ist das Ruhezeit-Fenster (lokale Zeit; Nullwert = deaktiviert).
// Quiet is the quiet window (local time; zero value = disabled).
Quiet QuietHours
// QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" verwirft
// still, "toast" ersetzt Audio durch eine Benachrichtigung — aktuell
// eine Protokoll-Zeile über den Notification-Pfad des Loggers; der
// echte OS-Toast folgt mit der Notification-Anbindung (dokumentierte
// Einschränkung, siehe Task-05-Report).
// QuietMode is the behaviour during quiet hours: "discard" drops
// silently, "toast" replaces audio with a desktop notification (the
// queue fires the OnToast hook; the daemon wires the notification).
QuietMode string
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (Runen).
// Längere Texte werden beim Eintritt an einer Wortgrenze gekürzt und
// als gekürzt gemeldet — ein Agent, der die Langantwort vorliest,
// kostet so weder Minuten noch API-Budget; der Nutzer hört den Anfang.
// 0 = unbegrenzt.
// MaxText caps announcement length in characters (runes). Longer texts
// are cut at a word boundary on admission and reported as truncated: an
// agent reading out its long answer costs neither minutes nor API
// budget, and the user still hears the beginning. 0 = unlimited.
MaxText int
// MaxAge ist die längste Wartezeit einer Ansage in der Queue. Was
// länger parkt (Rate-Limit, Stau), ist veraltet und wird beim
// Fälligwerden verworfen (DiscardExpired) statt gesprochen. 0 =
// unbegrenzt. now:true wartet nie lange und ist davon ausgenommen.
// MaxAge is the longest an announcement may wait in the queue. Whatever
// parks longer (rate limit, backlog) is stale and gets discarded when it
// comes due (DiscardExpired) instead of spoken. 0 = unlimited. now:true
// never waits long and is exempt.
MaxAge time.Duration
}
// verdict ist die Gesetzes-Entscheidung über ein fälliges Item.
// verdict is the laws' decision about a due item.
type verdict int
const (
verdictPlay verdict = iota // abspielen
verdictWait // parken bis zum freien Slot
verdictDiscardRate // verwerfen: low über dem Budget
verdictDiscardQuiet // verwerfen: Ruhezeit (discard)
verdictToast // Toast statt Audio (Ruhezeit)
verdictDiscardExpired // verwerfen: länger gewartet als MaxAge
verdictPlay verdict = iota // play
verdictWait // park until the slot opens
verdictDiscardRate // discard: low over budget
verdictDiscardQuiet // discard: quiet hours (discard)
verdictToast // toast instead of audio (quiet hours)
verdictDiscardExpired // discard: waited longer than MaxAge
)
// decide wendet die Gesetze auf ein fälliges Item an (rein, ohne Zustand):
// slotFree sagt, ob der Rate-Limit-Slot gerade frei ist. Rangordnung:
// decide applies the laws to a due item (pure, no state): slotFree says
// whether the rate-limit slot is open right now. Precedence:
//
// 1. now:true bricht bewusst aus — überspringt Rate-Limit UND Ruhezeit
// (dokumentierter Ausbruch für Dringendes; startet trotzdem ein neues
// Rate-Fenster).
// 2. Veraltet: Hat das Item länger als MaxAge gewartet, wird es verworfen
// — eine Ansage von vor fünf Minuten ist keine Nachricht mehr.
// 3. Ruhezeit: discard verwirft, toast ersetzt — gilt auch für high,
// denn Burst ist ein Rate-Recht, kein Ruhe-Recht.
// 4. Rate-Limit: freier Slot spielt; high spielt mit Burst-Recht; low
// wird verworfen; normal (und high ohne Recht) parken bis zum Slot.
// 1. now:true deliberately breaks out: it skips rate limit AND quiet hours
// (the documented escape for urgent matters; it still books a new rate
// window).
// 2. Stale: an item that waited longer than MaxAge is discarded; an
// announcement from five minutes ago is no longer news.
// 3. Quiet hours: discard drops, toast replaces; this applies to high too,
// because burst is a rate right, not a quiet right.
// 4. Rate limit: an open slot plays; high plays with burst rights; low is
// discarded; normal (and high without rights) park until the slot.
func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
if item.Now {
return verdictPlay
@@ -157,42 +153,43 @@ func (l Laws) decide(item Item, now time.Time, slotFree bool) verdict {
return verdictWait
}
// Admission ist das Ergebnis von Enqueue: Aufgenommene tragen ihre ID,
// verworfene nennen den Grund (die 202-Antwort von POST /speak führt ihn).
// Admission is the result of Enqueue: admitted items carry their ID,
// discarded ones name the reason (the 202 response of POST /speak carries
// it).
type Admission struct {
// ID der aufgenommenen Ansage (leer, wenn verworfen).
// ID of the admitted announcement (empty when discarded).
ID string
// Discarded ist der Verwurfs-Grund (DiscardRateLimit bzw.
// DiscardQuietHours); leer, wenn die Ansage aufgenommen wurde.
// Discarded is the discard reason (DiscardRateLimit or
// DiscardQuietHours); empty when the announcement was admitted.
Discarded string
// Toast meldet, dass die Ansage in der Ruhezeit durch eine
// Benachrichtigung ersetzt wurde (quiet_mode = "toast").
// Toast reports that the announcement was replaced by a notification
// during quiet hours (quiet_mode = "toast").
Toast bool
// Truncated meldet, dass der Text auf MaxText gekürzt wurde (die
// gekürzte Fassung ist aufgenommen).
// Truncated reports that the text was cut to MaxText (the shortened
// version was admitted).
Truncated bool
}
// LawState ist die /status-Sicht auf die Anti-Spam-Gesetze: konfiguriertes
// Rate-Limit mit dem nächsten freien Slot sowie das Ruhezeit-Fenster.
// LawState is the /status view of the laws: the configured rate limit with
// the next open slot, the quiet window, and the length and age limits.
type LawState struct {
RateLimit time.Duration
AllowHighBurst bool
NextSlot time.Time // Nullzeit = Slot frei (oder unbegrenzt)
NextSlot time.Time // zero = slot open (or unlimited)
Quiet QuietHours
QuietActive bool
QuietMode string
QuietUntil time.Time // Nullzeit = Ruhezeit inaktiv
MaxText int // 0 = unbegrenzt
MaxAge time.Duration // 0 = unbegrenzt
QuietUntil time.Time // zero = quiet hours inactive
MaxText int // 0 = unlimited
MaxAge time.Duration // 0 = unlimited
}
// truncateText kürzt s auf höchstens max Runen, bevorzugt an einer
// Wortgrenze (sofern die nicht in der ersten Hälfte liegt), und meldet, ob
// gekürzt wurde. max <= 0 = unbegrenzt.
// truncateText cuts s to at most max runes, preferably at a word boundary
// (unless that lies in the first half), and reports whether it cut.
// max <= 0 = unlimited.
func truncateText(s string, max int) (string, bool) {
if max <= 0 {
return s, false
+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