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

+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...)