Files
talk/.superpowers/sdd/plan/review-3b44605..dc94f45.diff
T

3814 lines
162 KiB
Diff
Raw Normal View History

# Review package: 3b44605..HEAD
## Commits
dc94f45 docs(readme): Daemon talkd, HTTP-Endpunkte, Mute-Persistenz und Degraded Mode dokumentieren
1ae1f12 test: Prozessgrenzen-Tests — talkd + talk als echte Prozesse gegen Fake-Provider und sink null
e1a3981 feat(cli): say über den Daemon, Steuer-Befehle als Daemon-Aufrufe, Degraded Mode
d09aff6 feat(talkd): Daemon mit HTTP-API auf 127.0.0.1, Queue-Betrieb und Shutdown
39c3563 feat(api): HTTP-Vertrag des Daemons — Endpunkte, Validierung und CLI-Client
e492bad feat(queue): serialisierte Ansage-Queue mit Prioritäten, now-Sprung, Mute-Store und Rate-Limit-Struktur
716f6b2 feat(config): Daemon-Port server.port mit Env-Override TALK_PORT (Default 7800)
## Files changed
README.md | 41 +++-
cmd/talk/cli.go | 56 +++---
cmd/talk/cli_test.go | 7 +-
cmd/talk/control.go | 126 ++++++++++++
cmd/talk/control_test.go | 118 +++++++++++
cmd/talk/daemon.go | 93 +++++++++
cmd/talk/degraded.go | 116 +++++++++++
cmd/talk/main.go | 10 +-
cmd/talk/say.go | 71 +++----
cmd/talk/say_test.go | 11 +-
cmd/talkd/main.go | 249 ++++++++++++++++++++++-
internal/api/client.go | 123 ++++++++++++
internal/api/doc.go | 8 +-
internal/api/server.go | 173 ++++++++++++++++
internal/api/types.go | 81 ++++++++
internal/config/config.go | 33 ++-
internal/config/config_test.go | 64 ++++++
internal/queue/doc.go | 7 +-
internal/queue/mute.go | 84 ++++++++
internal/queue/queue.go | 288 ++++++++++++++++++++++++++
internal/queue/queue_test.go | 309 ++++++++++++++++++++++++++++
internal/queue/rate.go | 57 ++++++
test/cli_test.go | 323 +++++++++++++++++++++++++++++
test/harness_test.go | 447 +++++++++++++++++++++++++++++++++++++++++
test/mute_test.go | 103 ++++++++++
test/queue_test.go | 273 +++++++++++++++++++++++++
26 files changed, 3169 insertions(+), 102 deletions(-)
## Diff
README.md --- Text
28 Say-Flags (gelten auch für `talk "Te 28 Say-Flags (gelten auch für `talk "Te
.. xt"`): `--wait`, `--now`, `--priorit .. xt"`): `--wait`, `--now`, `--priorit
.. y`, .. y`,
29 `--voice`, `--lang`, `--provider`. 29 `--voice`, `--lang`, `--provider`.
30 30
31 **Hinweis:** In diesem Stand ist `sa 31 **Hinweis:** In diesem Stand sind im
.. y` (und der Default-Aufruf .. plementiert: `say` (und `talk "Text"
.. .. `)
32 `talk "Text"`) implementiert — inklu 32 mit der kompletten Provider-Kette un
.. sive der kompletten Provider-Kette .. d Daemon-Anbindung (non-blocking übe
.. .. r
33 (ElevenLabs → OpenAI → Edge-TTS → OS ..
.. -Stimme, blockierend; ..
34 Daemon-Anbindung folgt). Alle andere 33 `talkd`, siehe unten), `mute`, `unmu
.. n Befehle sind Stubs und melden .. te`, `status`, `cancel`. Die übrigen
35 `not implemented yet` (Exit-Code ≠ 0 34 Befehle sind Stubs und melden `not i
.. ). .. mplemented yet` (Exit-Code ≠ 0).
.. 35
.. 36 ## Daemon talkd (Task 04)
.. 37
.. 38 `talkd` besitzt die Ansage-Queue und
.. .. spricht seriell ab — genau eine Ans
.. .. age
.. 39 gleichzeitig, Reihenfolge `now` > `h
.. .. igh` > `normal` > `low` (FIFO je
.. 40 Level). Der HTTP-Server lauscht nur
.. .. auf `127.0.0.1`; Port: Default `7800
.. .. `,
.. 41 config `[server] port`, Env `TALK_PO
.. .. RT` (schlagen den Default).
.. 42
.. 43 | Endpunkt | Wirkung |
.. 44 | --- | --- |
.. 45 | `POST /speak` | `{text, priority?,
.. .. provider?, voice?, lang?, now?}` →
.. .. `202 {"id":"…"}`; bei Mute: `202 {"i
.. .. d":"","muted":true}` (Ansage verworf
.. .. en — dokumentiertes Verhalten) |
.. 46 | `GET /status` | `{"version", "queu
.. .. e", "queue_ids", "current", "muted",
.. .. "provider"}` |
.. 47 | `POST /cancel` | bricht die laufen
.. .. de Ansage ab + leert die Queue → `20
.. .. 0 {"cancelled":n}` |
.. 48 | `POST /mute` / `POST /unmute` | pe
.. .. rsistenter Mute-State → `200 {"muted
.. .. ":bool}` |
.. 49
.. 50 - **CLI-Verhalten:** `talk say` (und
.. .. `talk "Text"`) erkennt den Daemon v
.. .. ia
.. 51 `GET /status`. Läuft er: enqueue +
.. .. sofortige Rückkehr (non-blocking,
.. 52 stdout erhält die Item-ID); `--wai
.. .. t` pollt bis zur Fertigstellung;
.. 53 `--now` springt vor die Warteschla
.. .. nge; bei Mute sagt die CLI `muted`.
.. 54 `talk mute`/`unmute`/`status`/`can
.. .. cel` sind Daemon-Aufrufe mit plain
.. 55 Output.
.. 56 - **Mute ist persistent** (Datei `mu
.. .. te` im Config-Dir, Inhalt `true`/
.. 57 `false`) und überlebt Daemon-Resta
.. .. rts. `talk mute` ohne laufenden Daem
.. .. on
.. 58 schreibt die Datei direkt — der Da
.. .. emon liest sie bei jeder Ansage neu,
.. 59 der Zustand greift also auch offli
.. .. ne.
.. 60 - **Degraded Mode:** Antwortet kein
.. .. Daemon, spielt `talk` lokal und
.. 61 sequenziell weiter — serialisiert
.. .. über die Lock-Datei `talk.lock` im
.. 62 Config-Dir (Kollision = warten; ve
.. .. rwaisste Locks gelten nach 60 s als
.. 63 verfallen). Mute wird auch hier re
.. .. spektiert. Nichts bricht still.
.. 64 - Rate-Limit und Ruhezeiten folgen i
.. .. n Task 05; die Rate-Limit-Struktur
.. 65 existiert bereits (`internal/queue
.. .. `), ist in diesem Stand aber bewusst
.. 66 noch nicht angebunden (Default: un
.. .. begrenzt).
36 67
37 ### Provider-Kette (Task 03) 68 ### Provider-Kette (Task 03)
38 69
cmd/talk/cli.go --- 1/7 --- Go
26 args string // Positionsargumente-Hinweis für die Usage-Zeile
27 detail string // Beschreibung für `talk <Befehl> --help`
28 register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
29 handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // Ausführung (fehlt sie: Stub)
30 }
31
32 // commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
cmd/talk/cli.go --- 2/7 --- Go
49 { 49 {
50 name: "mute", 50 name: "mute",
51 short: "Ansagen dauerhaft 51 short: "Ansagen dauerhaft
.. stummschalten", .. stummschalten",
52 detail: "Aktiviert den pers 52 detail: "Aktiviert den per
.. istenten Mute-State des Daemons (Da .. sistenten Mute-State (Datei `mute`
.. tei im Config-Verzeichnis).", .. im Config-Verzeichnis). Läuft der D
.. .. aemon, wird er darüber informiert;
.. .. andernfalls schreibt talk die Datei
.. .. direkt — der Zustand überlebt Daem
.. .. on-Restarts.",
.. 53 handler: runMute,
53 }, 54 },
54 { 55 {
55 name: "unmute", 56 name: "unmute",
56 short: "Stummschaltung auf 57 short: "Stummschaltung au
.. heben", .. fheben",
57 detail: "Hebt den persisten 58 detail: "Hebt den persiste
.. ten Mute-State des Daemons wieder a .. nten Mute-State wieder auf (Daemon-
.. uf.", .. Aufruf; ohne Daemon direkt über die
.. .. Datei).",
.. 59 handler: runUnmute,
58 }, 60 },
59 { 61 {
60 name: "status", 62 name: "status",
61 short: "Daemon-Status anze 63 short: "Daemon-Status anz
.. igen", .. eigen",
62 detail: "Zeigt Queue, aktue 64 detail: "Zeigt Queue, aktu
.. lles Item, Mute-State, aktiven Prov .. elles Item, Mute-State, aktiven Pro
.. ider und Daemon-Version.", .. vider und Daemon-Version (plain, ei
.. .. ne Zeile pro Feld).",
.. 65 handler: runStatus,
63 }, 66 },
64 { 67 {
65 name: "cancel", 68 name: "cancel",
66 short: "Laufende und gepla 69 short: "Laufende und gepl
.. nte Ansagen abbrechen", .. ante Ansagen abbrechen",
67 detail: "Bricht die laufend 70 detail: "Bricht die laufen
.. e Ansage ab und leert die Queue des .. de Ansage ab und leert die Queue de
.. Daemons.", .. s Daemons; Ausgabe ist die Zahl der
.. .. abgebrochenen Ansagen.",
.. 71 handler: runCancel,
68 }, 72 },
69 { 73 {
70 name: "voices", 74 name: "voices",
cmd/talk/cli.go --- 3/7 --- Go
90 94
91 // addSayFlags registriert die Flag 95 // addSayFlags registriert die Flag
.. s des say-Befehls und bindet sie an .. s des say-Befehls und bindet sie an
.. die .. die
92 // zurückgegebene Options-Struct. S 96 // zurückgegebene Options-Struct. S
.. ie gelten auch für den Default-Pfad .. ie gelten auch für den Default-Pfad
93 // `talk "Text"` (gleiche Flag-Ober 97 // `talk "Text"` (gleiche Flag-Ober
.. fläche, festgelegt in der Spec). Da .. fläche, festgelegt in der Spec). Ve
.. s .. rhalten
94 // Verhalten: direkter Pfad in Task 98 // seit Task 04: läuft ein Daemon,
.. 02, Daemon-Pfad in Task 04. .. wird enqueue (non-blocking); --wait
.. .. pollt
.. 99 // bis zur Fertigstellung, --now sp
.. .. ringt vor. Ohne Daemon: Degraded Mo
.. .. de.
95 // 100 //
96 // Rückgabe ist bewusst ein Pointer 101 // Rückgabe ist bewusst ein Pointer
.. : flag.Var bindet an die Felder gen ... : flag.Var bindet an die Felder gen
.. au ... au
97 // dieser Struct — eine Wertkopie h 102 // dieser Struct — eine Wertkopie h
.. ätte nach dem Parse weiterhin die ... ätte nach dem Parse weiterhin die
cmd/talk/cli.go --- 4/7 --- Go
120 125 writeUsage(stderr)
121 126 return exitUsage
122 127 }
123 128 return runSay(nil, &sayOptions{}, stdin, stdout, stderr)
124 129 }
125 130
126 131 if argv[0] == "help" {
cmd/talk/cli.go --- 5/7 --- Go
193 return fs, help, sayOpts 198 return fs, help, sayOpts
194 } 199 }
195 200
196 // runCommand führt einen benannten 201 // runCommand führt einen benannten
... Subcommand aus. say ist implementi ... Subcommand aus. Implementiert: say
... ert ...
... 202 // (Task 02/04) sowie mute/unmute/s
... ... tatus/cancel als Daemon-Aufrufe (Ta
... ... sk
197 // (Task 02); alle anderen sind Stu 203 // 04); die restlichen sind Stubs b
... bs bis zu ihren Tasks. Flags dürfen ... is zu ihren Tasks. Flags dürfen auc
... auch ... h
198 // nach dem Text stehen (parseInter 204 // nach dem Text stehen (parseInter
... spersed). ... spersed).
199 func runCommand(cmd command, args [ 205 func runCommand(cmd command, args [
... ]string, stdin io.Reader, stdout, s ... ]string, stdin io.Reader, stdout, s
... tderr io.Writer) int { ... tderr io.Writer) int {
200 fs, help, sayOpts := newCommand 206 fs, help, sayOpts := newCommand
... FlagSet(cmd, stderr) ... FlagSet(cmd, stderr)
cmd/talk/cli.go --- 6/7 --- Go
213 219 return exitOK
214 220 }
215 221 if cmd.handler != nil {
216 222 return cmd.handler(positional, sayOpts, stdin, stdout, stderr)
217 223 }
218 224 return notImplemented(cmd.name, stderr)
219 225 }
cmd/talk/cli.go --- 7/7 --- Go
245 251 writeUsage(stdout)
246 252 return exitOK
247 253 }
248 254 return runSay(positional, sayOpts, stdin, stdout, stderr)
249 255 }
250 256
251 257 // versionString rendert die Versionsausgabe: `talk version <version>` plus
cmd/talk/cli_test.go --- Go
14 "say", "wrap", "mute", "unmute", 14 "say", "wrap", "mute", "unmute",
.. "status", "cancel", "voices", "test .. "status", "cancel", "voices", "test
.. ", "doctor", "setup", .. ", "doctor", "setup",
15 } 15 }
16 16
17 // wantStubCommands ist wantSubcomma 17 // wantStubCommands ist wantSubcomma
.. nds ohne say — say ist seit Task 02 .. nds ohne die implementierten Befehle
.. .. —
.. 18 // say (Task 02/04) sowie mute/unmut
.. .. e/status/cancel (Task 04, Daemon-
18 // implementiert, alle anderen bleib 19 // Aufrufe) sind real; die restliche
.. en Stubs bis zu ihren Tasks. .. n bleiben Stubs bis zu ihren Tasks.
19 var wantStubCommands = []string{ 20 var wantStubCommands = []string{
20 "wrap", "mute", "unmute", "statu 21 "wrap", "voices", "test", "docto
.. s", "cancel", "voices", "test", "doc .. r", "setup",
.. tor", "setup", ..
21 } 22 }
22 23
23 // runCLI ist der Test-Einstieg in r 24 // runCLI ist der Test-Einstieg in r
.. un mit leerem stdin (kein Text über .. un mit leerem stdin (kein Text über
cmd/talk/control.go --- Go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "path/filepath"
8
9 "code.m3ta.dev/m3tam3re/talk/internal/api"
10 "code.m3ta.dev/m3tam3re/talk/internal/config"
11 "code.m3ta.dev/m3tam3re/talk/internal/queue"
12 )
13
14 // Die Steuer-Befehle (Task 04) sind Daemon-Aufrufe mit plain Output:
15 // stdout führt eine maschinenlesbare Zeile (muted/unmuted, cancelled: N
16 // bzw. den Status-Block), stderr menschliche Hinweise.
17
18 // runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
19 // Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
20 // Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
21 // Zustand greift also auch offline und überlebt Daemon-Restarts.
22 func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
23 return setMute(true, stdout, stderr)
24 }
25
26 // runUnmute implementiert `talk unmute` (analog zu runMute).
27 func runUnmute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
28 return setMute(false, stdout, stderr)
29 }
30
31 // setMute ist der gemeinsame Kern von mute/unmute.
32 func setMute(muted bool, stdout, stderr io.Writer) int {
33 cfg, err := config.Load(config.Options{Getenv: getenv})
34 if err != nil {
35 fmt.Fprintf(stderr, "talk: %v\n", err)
36 return exitFailure
37 }
38 client := api.NewClient(cfg.Server.Port)
39 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
40 defer cancel()
41 if err := client.SetMute(ctx, muted); err == nil {
42 printMuteState(muted, stdout)
43 return exitOK
44 }
45
46 // Daemon-Aufruf fehlgeschlagen (meist: nicht erreichbar): Datei direkt
47 // schreiben — der Daemon (und der Degraded Mode) liest sie bei jeder
48 // Ansage neu.
49 fmt.Fprintf(stderr, "talk: Daemon-Aufruf fehlgeschlagen (%v) — Mute-Datei wird direkt geschrieben\n", err)
50 dir, err := config.Dir(getenv)
51 if err != nil {
52 fmt.Fprintf(stderr, "talk: %v\n", err)
53 return exitFailure
54 }
55 if err := (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Set(muted); err != nil {
56 fmt.Fprintf(stderr, "talk: Mute-Datei schreiben: %v\n", err)
57 return exitFailure
58 }
59 printMuteState(muted, stdout)
60 return exitOK
61 }
62
63 // printMuteState schreibt den Zustand als plain Token auf stdout.
64 func printMuteState(muted bool, stdout io.Writer) {
65 if muted {
66 fmt.Fprintln(stdout, "muted")
67 } else {
68 fmt.Fprintln(stdout, "unmuted")
69 }
70 }
71
72 // runStatus implementiert `talk status`: plain Block aus GET /status —
73 // Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
74 // aktiver Provider. Ohne Daemon: klarer Fehler (status ist eine Anfrage an
75 // den Daemon; der Degraded Mode hat keinen Status).
76 func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
77 cfg, err := config.Load(config.Options{Getenv: getenv})
78 if err != nil {
79 fmt.Fprintf(stderr, "talk: %v\n", err)
80 return exitFailure
81 }
82 client := api.NewClient(cfg.Server.Port)
83 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
84 defer cancel()
85 st, err := client.Status(ctx)
86 if err != nil {
87 fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL)
88 return exitFailure
89 }
90
91 fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
92 fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
93 if st.Current != nil {
94 fmt.Fprintf(stdout, "current: %s %q (Priorität %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
95 } else {
96 fmt.Fprintln(stdout, "current: –")
97 }
98 muteState := "aus"
99 if st.Muted {
100 muteState = "an"
101 }
102 fmt.Fprintf(stdout, "mute: %s\n", muteState)
103 fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
104 return exitOK
105 }
106
107 // runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
108 // Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
109 // Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte.
110 func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
111 cfg, err := config.Load(config.Options{Getenv: getenv})
112 if err != nil {
113 fmt.Fprintf(stderr, "talk: %v\n", err)
114 return exitFailure
115 }
116 client := api.NewClient(cfg.Server.Port)
117 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
118 defer cancel()
119 n, err := client.Cancel(ctx)
120 if err != nil {
121 fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s — nichts zu abbrechen\n", client.BaseURL)
122 return exitFailure
123 }
124 fmt.Fprintf(stdout, "cancelled: %d\n", n)
125 return exitOK
126 }
127
cmd/talk/control_test.go --- Go
1 package main
2
3 import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "code.m3ta.dev/m3tam3re/talk/internal/queue"
11 )
12
13 // Diese Tests decken die Steuer-Befehle ohne Daemon ab (in-process über
14 // die run-Seam; TALK_PORT=1 aus der setGetenv-Grundausstattung → Daemon-
15 // Erkennung schlägt sofort fehl). Das Verhalten MIT Daemon testen die
16 // Prozessgrenzen-Tests in /test.
17
18 // TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
19 // mit Hinweis (kein Stillversagen).
20 func TestStatusOhneDaemon(t *testing.T) {
21 setGetenv(t, nil)
22 var out, errBuf bytes.Buffer
23 code := runCLI([]string{"status"}, &out, &errBuf)
24 if code != exitFailure {
25 t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
26 }
27 if !strings.Contains(errBuf.String(), "nicht erreichbar") {
28 t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
29 }
30 if out.Len() != 0 {
31 t.Errorf("stdout = %q, will leer", out.String())
32 }
33 }
34
35 // TestCancelOhneDaemon: ohne Daemon gibt es nichts zu abbrechen — klarer
36 // Fehler.
37 func TestCancelOhneDaemon(t *testing.T) {
38 setGetenv(t, nil)
39 var out, errBuf bytes.Buffer
40 code := runCLI([]string{"cancel"}, &out, &errBuf)
41 if code != exitFailure {
42 t.Fatalf("talk cancel ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
43 }
44 if !strings.Contains(errBuf.String(), "nichts zu abbrechen") {
45 t.Errorf("stderr = %q, will Hinweis", errBuf.String())
46 }
47 }
48
49 // TestMuteOhneDaemonSchreibtDatei: `talk mute` ohne Daemon schreibt die
50 // Mute-Datei direkt — und genau diese Datei respektiert der Degraded Mode
51 // von say anschließend.
52 func TestMuteOhneDaemonSchreibtDatei(t *testing.T) {
53 root := t.TempDir()
54 setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root})
55 mutePath := filepath.Join(root, "talk", queue.MuteFileName)
56
57 var out, errBuf bytes.Buffer
58 if code := runCLI([]string{"mute"}, &out, &errBuf); code != exitOK {
59 t.Fatalf("talk mute ohne Daemon: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
60 }
61 if got := out.String(); got != "muted\n" {
62 t.Errorf("talk mute stdout = %q, want %q", got, "muted\n")
63 }
64 if !strings.Contains(errBuf.String(), "direkt") {
65 t.Errorf("stderr = %q, will Hinweis auf direkten Datei-Schreib", errBuf.String())
66 }
67 b, err := os.ReadFile(mutePath)
68 if err != nil {
69 t.Fatalf("Mute-Datei fehlt: %v", err)
70 }
71 if strings.TrimSpace(string(b)) != "true" {
72 t.Errorf("Mute-Datei = %q, want true", b)
73 }
74
75 // say im Degraded Mode respektiert dieselbe Datei (tonlos verworfen,
76 // ohne irgendeinen Provider zu brauchen).
77 out.Reset()
78 errBuf.Reset()
79 if code := runCLI([]string{"say", "stille bitte"}, &out, &errBuf); code != exitOK {
80 t.Fatalf("talk say (mute an): Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
81 }
82 if got := out.String(); got != "muted\n" {
83 t.Errorf("talk say (mute an) stdout = %q, want %q", got, "muted\n")
84 }
85
86 // unmute hebt es auf (Datei false).
87 out.Reset()
88 errBuf.Reset()
89 if code := runCLI([]string{"unmute"}, &out, &errBuf); code != exitOK {
90 t.Fatalf("talk unmute: Exit-Code = %d (stderr: %q)", code, errBuf.String())
91 }
92 if got := out.String(); got != "unmuted\n" {
93 t.Errorf("talk unmute stdout = %q, want %q", got, "unmuted\n")
94 }
95 b, err = os.ReadFile(mutePath)
96 if err != nil || strings.TrimSpace(string(b)) != "false" {
97 t.Errorf("Mute-Datei nach unmute = %q (%v), want false", b, err)
98 }
99 }
100
101 // TestSayOhneConfigDir: Degraded Mode ohne Config-Dir (kein HOME) spielt
102 // weiter statt zu scheitern — Best-effort ohne Lock und Mute.
103 func TestSayOhneConfigDir(t *testing.T) {
104 seen := withFakeEspeak(t)
105 setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
106
107 var out, errBuf bytes.Buffer
108 code := runCLI([]string{"Hallo"}, &out, &errBuf)
109 if code != exitOK {
110 t.Fatalf("talk ohne HOME: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String())
111 }
112 if _, err := os.Stat(seen); err != nil {
113 t.Errorf("fake espeak nicht gerufen: %v", err)
114 }
115 if !strings.Contains(errBuf.String(), "Degraded Mode") {
116 t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
117 }
118 }
119
cmd/talk/daemon.go --- Go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "time"
9
10 "code.m3ta.dev/m3tam3re/talk/internal/api"
11 "code.m3ta.dev/m3tam3re/talk/internal/config"
12 )
13
14 // Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
15 // hält kurz: Auf einem freien Port wird die Verbindung sofort abgelehnt;
16 // das Budget deckt nur unglückliche Fälle (gefilterte Ports), damit talk
17 // ohne Daemon nicht spürbar trödelt.
18 const (
19 daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
20 daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
21 waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
22 )
23
24 // sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status,
25 // dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking),
26 // stdout erhält die Item-ID; --wait pollt /status, bis die Ansage weder
27 // läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage —
28 // stdout sagt „muted".
29 //
30 // handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den
31 // Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue
32 // (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus
33 // (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon
34 // angekommen und wurde bewusst abgelehnt.
35 func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) (code int, handled bool) {
36 client := api.NewClient(cfg.Server.Port)
37
38 probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout)
39 defer cancelProbe()
40 if _, err := client.Status(probeCtx); err != nil {
41 return 0, false
42 }
43
44 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
45 defer cancel()
46 resp, err := client.Speak(ctx, api.SpeakRequest{
47 Text: text,
48 Priority: opts.priority,
49 Provider: opts.provider,
50 Voice: opts.voice,
51 Lang: opts.lang,
52 Now: opts.now,
53 })
54 if err != nil {
55 var httpErr *api.HTTPError
56 if errors.As(err, &httpErr) {
57 fmt.Fprintf(stderr, "talk: Daemon hat die Ansage abgelehnt: %v\n", err)
58 return exitFailure, true
59 }
60 return 0, false
61 }
62 if resp.Muted {
63 fmt.Fprintln(stdout, "muted")
64 fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
65 return exitOK, true
66 }
67 fmt.Fprintln(stdout, resp.ID)
68 if !opts.wait {
69 return exitOK, true
70 }
71 return waitForItem(client, resp.ID, stderr), true
72 }
73
74 // waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
75 // sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
76 // nichts mehr zu warten). Stirbt der Daemon während des Wartens, ist das
77 // ein Fehler: Der Zustand der Ansage ist unbekannt, und still warten wäre
78 // das Gegenteil von --wait.
79 func waitForItem(client *api.Client, id string, stderr io.Writer) int {
80 for {
81 ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
82 st, err := client.Status(ctx)
83 cancel()
84 if err != nil {
85 fmt.Fprintf(stderr, "talk: Daemon beim Warten nicht mehr erreichbar (%v)\n", err)
86 return exitFailure
87 }
88 if !st.PendingOrCurrent(id) {
89 return exitOK
90 }
91 time.Sleep(waitPollInterval)
92 }
93 }
94
cmd/talk/degraded.go --- Go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "time"
11
12 "code.m3ta.dev/m3tam3re/talk/internal/audio"
13 "code.m3ta.dev/m3tam3re/talk/internal/config"
14 "code.m3ta.dev/m3tam3re/talk/internal/langdetect"
15 "code.m3ta.dev/m3tam3re/talk/internal/provider"
16 "code.m3ta.dev/m3tam3re/talk/internal/queue"
17 )
18
19 // Degraded Mode (Spec: „ohne talkd: Lock-File + sequenzielles Abspielen;
20 // nichts bricht still"). Die Lock-Datei talk.lock im Config-Dir
21 // serialisiert konkurrierende talk-Prozesse; Kollision heißt warten.
22 const (
23 lockFileName = "talk.lock"
24 lockStaleAfter = time.Minute // abgestürzter Prozess: Lock gilt als verwais
25 lockWaitTimeout = 10 * time.Second // Kollision: so lange auf das Lock warten
26 lockPollInterval = 50 * time.Millisecond
27 )
28
29 // sayDegraded ist der lokale Pfad ohne Daemon: mute respektieren (dieselbe
30 // Mute-Datei, die der Daemon liest), über die Lock-Datei serialisieren,
31 // dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im
32 // Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das
33 // Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und
34 // Mute weiter, statt zu scheitern.
35 func sayDegraded(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) int {
36 if dir, err := config.Dir(getenv); err == nil {
37 mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
38 if mute.Muted() {
39 fmt.Fprintln(stdout, "muted")
40 fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
41 return exitOK
42 }
43 release, err := acquireLock(filepath.Join(dir, lockFileName))
44 if err != nil {
45 fmt.Fprintf(stderr, "talk: Lock-Datei nicht erhalten (%v) — spiele ohne Lock (best-effort)\n", err)
46 } else {
47 defer release()
48 }
49 }
50
51 lang := opts.lang
52 if lang == "" {
53 lang = langdetect.Detect(text)
54 }
55
56 sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
57 if err != nil {
58 fmt.Fprintf(stderr, "talk: %v\n", err)
59 return exitFailure
60 }
61
62 chain, err := provider.NewChain(provider.ChainConfig{
63 Pin: pickProvider(opts.provider, cfg.Provider),
64 Voice: opts.voice,
65 Voices: cfg.Voice,
66 Endpoints: endpointsOf(cfg),
67 Model: cfg.Providers["openai"].Model,
68 Getenv: getenv,
69 Player: sink,
70 Log: stderr,
71 })
72 if err != nil {
73 fmt.Fprintf(stderr, "talk: %v\n", err)
74 return exitFailure
75 }
76
77 if err := chain.Speak(context.Background(), text, lang); err != nil {
78 fmt.Fprintf(stderr, "talk: %v\n", err)
79 return exitFailure
80 }
81 return exitOK
82 }
83
84 // acquireLock legt die Lock-Datei exklusiv an (O_CREATE|O_EXCL — reine
85 // stdlib, auf allen Plattformen verfügbar). Ist sie belegt, wird gepollt
86 // (Kollision = warten); wirkt sie verwais (Prozess-Crash), wird sie nach
87 // lockStaleAfter einmalig entfernt. Rückgabe ist die release-Funktion
88 // (Datei schließen und freigeben); ihr Fehler ist bewusst geschluckt —
89 // Best-effort heißt: ein hängengebliebenes Lock blockiert künftige Läufe
90 // nur bis zur Stale-Frist.
91 func acquireLock(path string) (release func(), err error) {
92 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
93 return nil, err
94 }
95 deadline := time.Now().Add(lockWaitTimeout)
96 for {
97 f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
98 if err == nil {
99 return func() {
100 f.Close()
101 _ = os.Remove(path)
102 }, nil
103 }
104 if !errors.Is(err, os.ErrExist) {
105 return nil, err
106 }
107 if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
108 _ = os.Remove(path)
109 continue
110 }
111 if !time.Now().Before(deadline) {
112 return nil, fmt.Errorf("Lock-Datei %s bleibt belegt", path)
113 }
114 time.Sleep(lockPollInterval)
115 }
116 }
117
cmd/talk/main.go --- Go
1 // talk ist die CLI für Sprach-Ausga 1 // talk ist die CLI für Sprach-Ausga
. ben (talk 2.0). . ben (talk 2.0).
2 // 2 //
3 // Dieser Stand (Task 02) liefert de 3 // Dieser Stand (Task 04): say (und
. n say-Pfad end-to-end: Text aus . der Default-Aufruf `talk "Text"`) re
. . icht
4 // Argument oder stdin → Spracherken 4 // Ansagen in den Daemon talkd ein —
. nung → Provider (osbuiltin) → . non-blocking, --wait pollt bis zur
5 // Wiedergabe über einen Audio-Sink 5 // Fertigstellung, --now springt vor
. (oto/null). Die übrigen Befehle sind . . Ohne Daemon fällt say in den Degra
. . ded
. 6 // Mode (Lock-Datei, sequenziell lok
. . al, mute respektiert). mute/unmute/
. 7 // status/cancel sind Daemon-Aufrufe
. . mit plain Output. Die übrigen Befeh
. . le
6 // Stubs; Daemon, Kette und TUI folg 8 // sind Stubs; TUI und weitere Aufga
. en in den Tasks 03–09. . ben folgen in den Tasks 05–09.
7 package main 9 package main
8 10
9 import "os" 11 import "os"
cmd/talk/say.go --- 1/3 --- Go
1 package main 1 package main
2 2
3 import ( 3 import (
4 "context" .
5 "fmt" 4 "fmt"
6 "io" 5 "io"
7 "os" 6 "os"
8 "strings" 7 "strings"
9 8
10 "code.m3ta.dev/m3tam3re/talk/in .
.. ternal/audio" .
11 "code.m3ta.dev/m3tam3re/talk/in 9 "code.m3ta.dev/m3tam3re/talk/in
.. ternal/config" . ternal/config"
12 "code.m3ta.dev/m3tam3re/talk/in .
.. ternal/langdetect" .
13 "code.m3ta.dev/m3tam3re/talk/in 10 "code.m3ta.dev/m3tam3re/talk/in
.. ternal/provider" .. ternal/queue"
14 ) 11 )
15 12
16 // getenv ist die Env-Seam von run 13 // getenv ist die Env-Seam von run
.. (Tests injizieren hier TALK_AUDIO_S .. (Tests injizieren hier TALK_AUDIO_S
.. INK .. INK,
17 // etc., ohne den Prozess-Environme 14 // TALK_PORT etc., ohne den Prozess
.. nt anzufassen). Default: os.Getenv. .. -Environment anzufassen). Default:
.. 15 // os.Getenv.
18 var getenv = os.Getenv 16 var getenv = os.Getenv
19 17
20 // sayOptions bündelt die Flags von 18 // sayOptions bündelt die Flags von
.. say (identisch im Default-Pfad .. say (identisch im Default-Pfad
cmd/talk/say.go --- 2/3 --- Go
28 provider string 26 provider string
29 } 27 }
30 28
31 // runSay ist der komplette Ansage- 29 // runSay ist der say-Eingangspfad:
.. Pfad (direkt und blockierend; .. Text aus Argumenten oder stdin sam
.. .. meln
32 // Daemon-Anbindung folgt in Task 0 30 // und validieren, dann Daemon-Anbi
.. 4): Text aus Argumenten oder stdin .. ndung (Task 04): Läuft ein talkd
.. → ..
33 // Spracherkennung → Provider-Kette 31 // (erkannt via GET /status), wird
.. (Task 03: elevenlabs → openai → ed .. die Ansage dort eingereiht — non-bl
.. ge → .. ocking
34 // osbuiltin; --provider/config pro 32 // als Default, --wait pollt bis zu
.. vider pinnen) → Synthese mit Fallba .. r Fertigstellung, --now springt vor
.. ck → .. ,
35 // Sink.Play. Auf Plattformen, dere 33 // Priorität/Provider/Voice/Lang we
.. n OS-TTS keine Bytes liefern kann ( .. rden durchgereicht. Antwortet kein
.. Linux ..
36 // ohne espeak), springt die dokume 34 // Daemon, fällt talk in den Degrad
.. ntierte Erweiterung provider.Direct .. ed Mode (Lock-Datei, sequenziell lo
.. Speaker .. kal,
37 // an (spd-say); mit audio.sink=nul 35 // mute respektiert — sayDegraded).
.. l wird die Ansage stattdessen verwo .. Die Provider-Kette selbst ist in b
.. rfen — .. eiden
38 // CI/Server bleiben ton- und fehle 36 // Pfarden dieselbe (internal/provi
.. rfrei. .. der, unverändert).
39 func runSay(args []string, opts *sa 37 func runSay(args []string, opts *sa
.. yOptions, stdin io.Reader, stderr i .. yOptions, stdin io.Reader, stdout,
.. o.Writer) int { .. stderr io.Writer) int {
40 text := strings.Join(args, " ") 38 text := strings.Join(args, " ")
41 if text == "" { 39 if text == "" {
42 if isCharDevice(stdin) { 40 if isCharDevice(stdin) {
cmd/talk/say.go --- 3/3 --- Go
55 } 53 }
56 } 54 }
57 55
.. 56 // Validierung vor jedem Daemon
.. .. -Kontakt: Benutzungsfehler sollen
.. 57 // schnell und ohne Netzwerk-Um
.. .. wege auffallen.
58 lang := opts.lang 58 if opts.lang != "" && opts.lang
.. .. != "de" && opts.lang != "en" {
59 if lang == "" { ..
60 lang = langdetect.Detect(te ..
.. xt) ..
61 } ..
62 if lang != "de" && lang != "en" ..
.. { ..
63 fmt.Fprintf(stderr, "talk: 59 fmt.Fprintf(stderr, "talk:
.. --lang: ungültiger Wert %q (erlaubt .. --lang: ungültiger Wert %q (erlaubt
.. : de, en)\n", lang) .. : de, en)\n", opts.lang)
64 return exitFailure 60 return exitFailure
65 } 61 }
66 ..
67 cfg, err := config.Load(config. ..
.. Options{Getenv: getenv}) ..
68 if err != nil { 62 if opts.priority != "" && !queu
.. .. e.Priority(opts.priority).Valid() {
69 fmt.Fprintf(stderr, "talk: ..
.. %v\n", err) ..
70 return exitFailure ..
71 } ..
72 ..
73 // Sink zuerst: Die Kette brauc ..
.. ht ihn als Player (Bytes-Wiedergabe ..
.. ) und ..
74 // für die null-Entscheidung be ..
.. i der Direktansage. ..
75 sink, err := audio.NewSink(cfg. ..
.. Audio.Sink, stderr) ..
76 if err != nil { ..
77 fmt.Fprintf(stderr, "talk: 63 fmt.Fprintf(stderr, "talk:
.. %v\n", err) .. --priority: ungültiger Wert %q (erl
.. .. aubt: low, normal, high)\n", opts.p
.. .. riority)
78 return exitFailure 64 return exitFailure
79 } 65 }
80 66
81 chain, err := provider.NewChain 67 cfg, err := config.Load(config.
.. (provider.ChainConfig{ .. Options{Getenv: getenv})
82 Pin: pickProvider(opt ..
.. s.provider, cfg.Provider), ..
83 Voice: opts.voice, ..
84 Voices: cfg.Voice, ..
85 Endpoints: endpointsOf(cfg) ..
.. , ..
86 Model: cfg.Providers["o ..
.. penai"].Model, ..
87 Getenv: getenv, ..
88 Player: sink, ..
89 Log: stderr, ..
90 }) ..
91 if err != nil { 68 if err != nil {
92 fmt.Fprintf(stderr, "talk: 69 fmt.Fprintf(stderr, "talk:
.. %v\n", err) .. %v\n", err)
93 return exitFailure 70 return exitFailure
94 } 71 }
95 72
96 ctx := context.Background() ..
97 if err := chain.Speak(ctx, text 73 if code, handled := sayViaDaemo
.. , lang); err != nil { .. n(cfg, text, opts, stdout, stderr);
.. .. handled {
98 fmt.Fprintf(stderr, "talk: ..
.. %v\n", err) ..
99 return exitFailure 74 return code
100 } 75 }
... 76
... 77 fmt.Fprintln(stderr, "talk: Dae
... .. mon nicht erreichbar — Degraded Mod
... .. e (lokal, sequenziell über Lock-Dat
... .. ei)")
101 return exitOK 78 return sayDegraded(cfg, text, o
... .. pts, stdout, stderr)
102 } 79 }
103 80
104 // pickProvider löst den Provider-P 81 // pickProvider löst den Provider-P
... in auf: CLI-Flag --provider schlägt .. in auf: CLI-Flag --provider schlägt
cmd/talk/say_test.go --- Go
14 // stattung jedes Tests: TALK_EDGE_E 14 // stattung jedes Tests: TALK_EDGE_E
.. NDPOINT zeigt auf einen garantiert .. NDPOINT zeigt auf einen garantiert
15 // toten Loopback-Port (Verbindung w 15 // toten Loopback-Port (Verbindung w
.. ird sofort abgelehnt) — die Default- .. ird sofort abgelehnt) — die Default-
16 // Kette enthält edge, und kein Test 16 // Kette enthält edge, und kein Test
.. darf je den echten Microsoft-Endpun .. darf je den echten Microsoft-Endpun
.. kt .. kt
.. 17 // anfassen. Dasselbe gilt seit Task
.. .. 04 für TALK_PORT: Die Daemon-Erkenn
.. .. ung
.. 18 // von say zeigt auf einen toten Por
.. .. t, damit die Tests hier Deterministi
.. .. sch
.. 19 // den Degraded Mode laufen (und nie
.. .. einen zufällig laufenden echten tal
.. .. kd
17 // anfassen. Wer edge bewusst anders 20 // treffen). Wer einen dieser Ports
.. braucht, überschreibt den Key. .. bewusst anders braucht, überschreibt
.. 21 // den Key.
18 func setGetenv(t *testing.T, kv map[ 22 func setGetenv(t *testing.T, kv map[
.. string]string) { .. string]string) {
19 t.Helper() 23 t.Helper()
20 base := map[string]string{"TALK_ 24 base := map[string]string{
.. EDGE_ENDPOINT": "ws://127.0.0.1:1/"} ..
.. 25 "TALK_EDGE_ENDPOINT": "ws://
.. .. 127.0.0.1:1/",
.. 26 "TALK_PORT": "1",
.. 27 }
21 for k, v := range kv { 28 for k, v := range kv {
22 base[k] = v 29 base[k] = v
cmd/talkd/main.go --- Go
1 // talkd ist der Daemon für talk 2. 1 // talkd ist der Daemon für talk 2.
. 0 (Queue, Mute-State, HTTP-API, MCP . 0: HTTP-API auf 127.0.0.1 (Default-
. ). . Port
. 2 // 7800, server.port/TALK_PORT), se
. . rialisierte Ansage-Queue mit Priori
. . täten
. 3 // und now-Sprung, persistenter Mut
. . e-State (Datei mute im Config-Dir).
2 // 4 //
3 // Dieser Stand (Task 01) ist ein P 5 // Task 04: POST /speak, GET /statu
. latzhalter: talkd startet, loggt ei . s, POST /cancel, POST /mute, POST /
. ne Zeile . unmute.
4 // und beendet sich mit Exit-Code 0 6 // Rate-Limit/Ruhezeiten folgen in
. . Die eigentliche Daemon-Implementi . Task 05, CORS/MCP in Task 06 — die
. erung .
5 // (HTTP-Server auf 127.0.0.1, seri 7 // Rate-Limit-Struktur existiert be
. alisierte Ansage-Queue, persistente . reits (internal/queue/rate.go), ist
. s Mute) . hier
6 // entsteht in Task 04. 8 // aber bewusst noch nicht angebund
. . en (Task-04-Default: unbegrenzt).
7 package main 9 package main
8 10
9 import "log" 11 import (
. 12 "context"
. 13 "fmt"
. 14 "log"
. 15 "net"
. 16 "net/http"
. 17 "os"
. 18 "os/signal"
. 19 "path/filepath"
. 20 "strings"
. 21 "sync"
. 22 "syscall"
. 23 "time"
. 24
. 25 "code.m3ta.dev/m3tam3re/talk/in
. .. ternal/api"
. 26 "code.m3ta.dev/m3tam3re/talk/in
. .. ternal/audio"
. 27 "code.m3ta.dev/m3tam3re/talk/in
. .. ternal/config"
. 28 "code.m3ta.dev/m3tam3re/talk/in
. .. ternal/langdetect"
. 29 "code.m3ta.dev/m3tam3re/talk/in
. .. ternal/provider"
. 30 "code.m3ta.dev/m3tam3re/talk/in
. .. ternal/queue"
. 31 )
. 32
. 33 // Build-Informationen. Werden beim
. .. Release per -ldflags injiziert (gl
. .. eicher
. 34 // Vertrag wie cmd/talk, siehe .gor
. .. eleaser.yaml in Task 11). Default:
. .. dev.
. 35 var (
. 36 version = "dev"
. 37 commit = ""
. 38 )
10 39
11 func main() { 40 func main() {
12 log.Println("talkd: Platzhalter 41 log.SetFlags(log.LstdFlags)
.. — Daemon-Implementierung folgt in ..
.. Task 04") ..
.. 42 log.SetPrefix("talkd: ")
.. 43 if err := run(); err != nil {
.. 44 log.Fatal(err)
.. 45 }
13 } 46 }
47
48 // run startet den Daemon und kehrt
.. .. erst beim Herunterfahren (SIGINT/
49 // SIGTERM) zurück. Abbrüche der la
.. .. ufenden Ansage beim Shutdown sind
50 // beabsichtigt: talkd soll auf Sig
.. .. nale prompt reagieren.
51 func run() error {
52 getenv := os.Getenv
53
54 cfg, err := config.Load(config.
.. .. Options{Getenv: getenv})
55 if err != nil {
56 return err
57 }
58
59 // Audio-Sink (audio.sink; "nul
.. .. l" verwirft tonlos — CI/Server).
60 // Ketten-, Queue- und Sink-Pro
.. .. tokoll laufen über denselben Logger
.. .. .
61 lw := lineLogger{}
62 sink, err := audio.NewSink(cfg.
.. .. Audio.Sink, lw)
63 if err != nil {
64 return err
65 }
66
67 // Basiskette nur für die /stat
.. .. us-Auflösung „aktiver Provider" (er
.. .. ste
68 // verfügbare Stufe; Available
.. .. ist billig — Key-/Endpunkt-Binary-C
.. .. hecks
69 // ohne Netz-Call). Das eigentl
.. .. iche Abspielen baut pro Item eine e
.. .. igene
70 // Kette (Pin/Voice je Anfrage)
.. .. — die Kette aus internal/provider
.. .. wird
71 // unverändert wiederverwendet.
72 baseChain, err := provider.NewC
.. .. hain(chainConfigFor(cfg, "", "", ge
.. .. tenv, nil))
73 if err != nil {
74 return err
75 }
76
77 state := &providerState{}
78 speak := func(ctx context.Conte
.. .. xt, item queue.Item) error {
79 state.set(resolveProviderNa
.. .. me(baseChain, item))
80 lang := item.Lang
81 if lang == "" {
82 lang = langdetect.Detec
.. .. t(item.Text)
83 }
84 chain, err := provider.NewC
.. .. hain(chainConfigFor(cfg, item.Provi
.. .. der, item.Voice, getenv, sink))
85 if err != nil {
86 return err
87 }
88 return chain.Speak(ctx, ite
.. .. m.Text, lang)
89 }
90
91 q := queue.New(speak, lw)
92
93 // Persistenter Mute-State (Dat
.. .. ei mute im Config-Dir). Ohne Config
.. .. -Dir
94 // läuft der Daemon ohne Mute w
.. .. eiter (dokumentierte Warnung) statt
.. .. zu
95 // scheitern — alles andere an
.. .. talk darf ja auch ohne Config-Datei
.. .. .
96 var mutePath string
97 if dir, err := config.Dir(geten
.. .. v); err == nil {
98 mutePath = filepath.Join(di
.. .. r, queue.MuteFileName)
99 } else {
100 log.Printf("warnung: Config
.. ... -Dir nicht bestimmbar — Mute-State
.. ... deaktiviert (%v)", err)
101 }
102 mute := queue.MuteStore{Path: m
.. ... utePath}
103
104 handler := api.NewHandler(api.H
.. ... andlerConfig{
105 Queue: q,
106 Mute: mute,
107 Version: versionStri
.. ... ng(),
108 KnownProviders: provider.Na
.. ... mes(),
109 Provider: func() string {
110 if cur, ok := q.Current
.. ... (); ok {
111 return resolveProvi
.. ... derName(baseChain, cur)
112 }
113 if last := state.get();
.. ... last != "" {
114 return last
115 }
116 return resolveProviderN
.. ... ame(baseChain, queue.Item{})
117 },
118 })
119
120 addr := fmt.Sprintf("127.0.0.1:
.. ... %d", cfg.Server.Port)
121 ln, err := net.Listen("tcp", ad
.. ... dr)
122 if err != nil {
123 return fmt.Errorf("Port bel
.. ... egen (%s): %w — läuft bereits ein t
.. ... alkd?", addr, err)
124 }
125 srv := &http.Server{Handler: ha
.. ... ndler, ReadHeaderTimeout: 5 * time.
.. ... Second}
126 errCh := make(chan error, 1)
127 go func() { errCh <- srv.Serve(
.. ... ln) }()
128 log.Printf("listening on http:/
.. ... /%s (version %s, audio sink %s, mut
.. ... e=%v)",
129 addr, versionString(), sink
.. ... .Name(), mute.Muted())
130
131 sig := make(chan os.Signal, 1)
132 signal.Notify(sig, os.Interrupt
.. ... , syscall.SIGTERM)
133
134 select {
135 case err := <-errCh:
136 q.Close()
137 return fmt.Errorf("http ser
.. ... ver: %w", err)
138 case s := <-sig:
139 log.Printf("Signal %v empfa
.. ... ngen — fahre herunter (laufende Ans
.. ... age wird abgebrochen)", s)
140 }
141
142 // Reihenfolge: erst HTTP dicht
.. ... (keine neuen Anfragen), dann die Q
.. ... ueue
143 // schließen (laufende Ansage a
.. ... bbrechen, Worker beenden und darauf
144 // warten). So läuft nie eine A
.. ... nsage nach dem Daemon-Ende weiter.
145 shutdownCtx, cancel := context.
.. ... WithTimeout(context.Background(), 3
.. ... *time.Second)
146 defer cancel()
147 if err := srv.Shutdown(shutdown
.. ... Ctx); err != nil {
148 log.Printf("http shutdown:
.. ... %v", err)
149 }
150 q.Close()
151 return nil
152 }
153
154 // versionString rendert die Daemon
.. ... -Version für /status.
155 func versionString() string {
156 if commit == "" {
157 return version
158 }
159 return fmt.Sprintf("%s (%s)", v
.. ... ersion, commit)
160 }
161
162 // chainConfigFor überführt Config
.. ... + Item-Pins in die ChainConfig — di
.. ... eselbe
163 // Zusammensetzung wie im CLI-Pfad
.. ... (cmd/talk/say.go), nur dass der Dae
.. ... mon
164 // sie pro Ansage baut (Pin/Voice s
.. ... ind Anfrage-Properties). player ist
.. ... nur
165 // für die Abspiel-Kette gesetzt (d
.. ... ie Basiskette braucht ihn nicht).
166 func chainConfigFor(cfg config.Conf
.. ... ig, pin, voice string, getenv func(
.. ... string) string, player provider.Pla
.. ... yer) provider.ChainConfig {
167 return provider.ChainConfig{
168 Pin: pickProvider(pin
.. ... , cfg.Provider),
169 Voice: voice,
170 Voices: cfg.Voice,
171 Endpoints: endpointsOf(cfg)
.. ... ,
172 Model: cfg.Providers["o
.. ... penai"].Model,
173 Getenv: getenv,
174 Player: player,
175 Log: lineLogger{},
176 }
177 }
178
179 // pickProvider löst den Provider-P
.. ... in auf: Anfrage-/Flag-Pin schlägt
180 // `provider = "…"` aus der config.
.. ... toml; leer = komplette Default-Kett
.. ... e.
181 func pickProvider(pin, cfgValue str
.. ... ing) string {
182 if pin != "" {
183 return pin
184 }
185 return cfgValue
186 }
187
188 // endpointsOf überführt die Provid
.. ... er-Einstellungen der Config in die
189 // Endpunkt-Map der Kette.
190 func endpointsOf(cfg config.Config)
.. ... map[string]string {
191 endpoints := make(map[string]st
.. ... ring, len(cfg.Providers))
192 for name, settings := range cfg
.. ... .Providers {
193 if settings.Endpoint != ""
.. ... {
194 endpoints[name] = setti
.. ... ngs.Endpoint
195 }
196 }
197 return endpoints
198 }
199
200 // resolveProviderName löst den „ak
.. ... tiven Provider" für /status auf: Pi
.. ... n des
201 // Items, sonst erste verfügbare St
.. ... ufe der Basiskette. Available() ist
202 // bewusst billig (Key-/Binary-Prüf
.. ... ung ohne Netz-Call), deshalb darf d
.. ... as
203 // /status on demand aufrufen.
204 func resolveProviderName(chain *pro
.. ... vider.Chain, item queue.Item) strin
.. ... g {
205 if item.Provider != "" {
206 return item.Provider
207 }
208 for _, p := range chain.Tiers()
.. ... {
209 if p.Available(context.Back
.. ... ground()) {
210 return p.Name()
211 }
212 }
213 return "keine"
214 }
215
216 // providerState merkt sich den zul
.. ... etzt aktiven Provider (für /status
.. ... im
217 // Leerlauf). Geschrieben wird auss
.. ... chließlich vom Queue-Worker, gelese
.. ... n von
218 // HTTP-Handlern — daher der eigene
.. ... kleine Mutex.
219 type providerState struct {
220 mu sync.Mutex
221 name string
222 }
223
224 func (s *providerState) set(name st
.. ... ring) {
225 s.mu.Lock()
226 s.name = name
227 s.mu.Unlock()
228 }
229
230 func (s *providerState) get() strin
.. ... g {
231 s.mu.Lock()
232 defer s.mu.Unlock()
233 return s.name
234 }
235
236 // lineLogger reicht Zeilen an den
.. ... Standard-Logger weiter (Zeitstempel
.. ... +
237 // talkd-Präfix), statt sie roh zu
.. ... schreiben: Provider-Kette, Queue un
.. ... d
238 // null-Sink schreiben zeilenweise,
.. ... so entsteht ein einheitliches Log.
239 type lineLogger struct{}
240
241 func (lineLogger) Write(p []byte) (
.. ... int, error) {
242 for _, line := range strings.Sp
.. ... lit(strings.TrimRight(string(p), "\
.. ... n"), "\n") {
243 if line != "" {
244 log.Print(line)
245 }
246 }
247 return len(p), nil
248 }
internal/api/client.go --- Go
1 package api
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 "time"
12 )
13
14 // Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
15 // dieselbe Vertragsschnittstelle, die server.go bedient.
16 type Client struct {
17 // BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
18 BaseURL string
19
20 // HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
21 HTTP *http.Client
22 }
23
24 // NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
25 // Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
26 // großzügiger); das Client-Timeout ist nur die harte Decke.
27 func NewClient(port int) *Client {
28 return &Client{
29 BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
30 HTTP: &http.Client{Timeout: 10 * time.Second},
31 }
32 }
33
34 // HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
35 // 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
36 // *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
37 // Netz-Fehler → Degraded Mode.
38 type HTTPError struct {
39 Status int
40 Body string
41 }
42
43 func (e *HTTPError) Error() string {
44 return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
45 }
46
47 // Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
48 func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
49 var st StatusResponse
50 if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
51 return nil, err
52 }
53 return &st, nil
54 }
55
56 // Speak ruft POST /speak (enqueue).
57 func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
58 var resp SpeakResponse
59 if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
60 return nil, err
61 }
62 return &resp, nil
63 }
64
65 // Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
66 // Ansagen.
67 func (c *Client) Cancel(ctx context.Context) (int, error) {
68 var resp CancelResponse
69 if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
70 return 0, err
71 }
72 return resp.Cancelled, nil
73 }
74
75 // SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
76 func (c *Client) SetMute(ctx context.Context, muted bool) error {
77 path := "/mute"
78 if !muted {
79 path = "/unmute"
80 }
81 return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
82 }
83
84 // do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
85 // dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
86 // über errors.As(&HTTPError)).
87 func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
88 var rd io.Reader
89 if body != nil {
90 b, err := json.Marshal(body)
91 if err != nil {
92 return err
93 }
94 rd = bytes.NewReader(b)
95 }
96 req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
97 if err != nil {
98 return err
99 }
100 if body != nil {
101 req.Header.Set("Content-Type", "application/json")
102 }
103 client := c.HTTP
104 if client == nil {
105 client = http.DefaultClient
106 }
107 resp, err := client.Do(req)
108 if err != nil {
109 return err
110 }
111 defer resp.Body.Close()
112 data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
113 if err != nil {
114 return err
115 }
116 if resp.StatusCode != wantStatus {
117 return &HTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(data))}
118 }
119 if out == nil {
120 return nil
121 }
122 return json.Unmarshal(data, out)
123 }
124
internal/api/doc.go --- Go
1 // Package api implementiert die HTTP 1 // Package api definiert die HTTP-Obe
. -Oberfläche des Daemons auf 127.0.0.1 . rfläche des Daemons auf 127.0.0.1:
. : .
. 2 // POST /speak (enqueue, 202 {id}; be
. . i Mute 202 {muted:true}), GET /status
. . ,
2 // POST /speak, GET /status, POST /ca 3 // POST /cancel, POST /mute, POST /un
. ncel, POST /mute, POST /unmute (Task . mute (Task 04). CORS für /speak und
. 04), .
3 // CORS für /speak und den MCP-Endpoi 4 // der MCP-Endpoint (Streamable HTTP)
. nt (Streamable HTTP, Task 06). . folgen in Task 06. Der Client-Teil
. 5 // ist die CLI-Seite derselben Vertra
. . gsschnittstelle.
4 package api 6 package api
internal/api/server.go --- Go
1 package api
2
3 import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "strings"
8
9 "code.m3ta.dev/m3tam3re/talk/internal/queue"
10 )
11
12 // maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
13 // versehentliche Riesen-Payloads).
14 const maxBodyBytes = 64 << 10
15
16 // HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
17 // kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
18 // injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
19 type HandlerConfig struct {
20 // Queue ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
21 Queue *queue.Queue
22
23 // Mute ist der persistente Mute-State (Datei mute im Config-Dir).
24 Mute queue.MuteStore
25
26 // Version ist die Daemon-Version für GET /status.
27 Version string
28
29 // KnownProviders sind die gültigen Provider-Namen (Validierung des
30 // provider-Felds in POST /speak; talkd liefert provider.Names()).
31 KnownProviders []string
32
33 // Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
34 // Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
35 Provider func() string
36 }
37
38 // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
39 // ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404.
40 func NewHandler(cfg HandlerConfig) http.Handler {
41 h := &handler{
42 queue: cfg.Queue,
43 mute: cfg.Mute,
44 version: cfg.Version,
45 known: make(map[string]bool, len(cfg.KnownProviders)),
46 provider: cfg.Provider,
47 }
48 for _, name := range cfg.KnownProviders {
49 h.known[name] = true
50 }
51 mux := http.NewServeMux()
52 mux.HandleFunc("POST /speak", h.speak)
53 mux.HandleFunc("GET /status", h.status)
54 mux.HandleFunc("POST /cancel", h.cancel)
55 mux.HandleFunc("POST /mute", h.muteEndpoint(true))
56 mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
57 return mux
58 }
59
60 // handler ist der interne Zustand der HTTP-Oberfläche.
61 type handler struct {
62 queue *queue.Queue
63 mute queue.MuteStore
64 version string
65 known map[string]bool
66 provider func() string
67 }
68
69 // speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
70 // dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
71 // Sonderbehandlung brauchen), sonst enqueue → 202 {id}.
72 func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
73 var req SpeakRequest
74 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
75 writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
76 return
77 }
78 if strings.TrimSpace(req.Text) == "" {
79 writeError(w, http.StatusBadRequest, "text fehlt oder ist leer")
80 return
81 }
82 if req.Priority == "" {
83 req.Priority = string(queue.PriorityNormal)
84 }
85 if !queue.Priority(req.Priority).Valid() {
86 writeError(w, http.StatusBadRequest, fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority))
87 return
88 }
89 if req.Provider != "" && !h.known[req.Provider] {
90 writeError(w, http.StatusBadRequest, fmt.Sprintf("provider: unbekannter Wert %q", req.Provider))
91 return
92 }
93 if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
94 writeError(w, http.StatusBadRequest, fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang))
95 return
96 }
97
98 if h.mute.Muted() {
99 writeJSON(w, http.StatusAccepted, SpeakResponse{Muted: true})
100 return
101 }
102
103 id, err := h.queue.Enqueue(queue.Item{
104 Text: req.Text,
105 Lang: req.Lang,
106 Provider: req.Provider,
107 Voice: req.Voice,
108 Priority: queue.Priority(req.Priority),
109 Now: req.Now,
110 })
111 if err != nil {
112 writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error())
113 return
114 }
115 writeJSON(w, http.StatusAccepted, SpeakResponse{ID: id})
116 }
117
118 // status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
119 // Item, Mute-State, aktiver Provider, Version.
120 func (h *handler) status(w http.ResponseWriter, r *http.Request) {
121 pending := h.queue.Pending()
122 ids := make([]string, len(pending))
123 for i, it := range pending {
124 ids[i] = it.ID
125 }
126 st := StatusResponse{
127 Version: h.version,
128 Queue: len(pending),
129 QueueIDs: ids,
130 Muted: h.mute.Muted(),
131 }
132 if cur, ok := h.queue.Current(); ok {
133 st.Current = &CurrentItem{
134 ID: cur.ID,
135 Text: cur.Text,
136 Priority: string(cur.Priority),
137 Now: cur.Now,
138 }
139 }
140 if h.provider != nil {
141 st.Provider = h.provider()
142 }
143 writeJSON(w, http.StatusOK, st)
144 }
145
146 // cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
147 func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
148 writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()})
149 }
150
151 // muteEndpoint behandelt POST /mute bzw. POST /unmute: persistenter
152 // Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
153 func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
154 return func(w http.ResponseWriter, r *http.Request) {
155 if err := h.mute.Set(muted); err != nil {
156 writeError(w, http.StatusInternalServerError, err.Error())
157 return
158 }
159 writeJSON(w, http.StatusOK, MuteResponse{Muted: muted})
160 }
161 }
162
163 // writeJSON schreibt eine JSON-Antwort mit Status-Code.
164 func writeJSON(w http.ResponseWriter, code int, body any) {
165 w.Header().Set("Content-Type", "application/json")
166 w.WriteHeader(code)
167 _ = json.NewEncoder(w).Encode(body)
168 }
169
170 // writeError schreibt einen Fehler als {"error": "…"} mit Status-Code.
171 func writeError(w http.ResponseWriter, code int, msg string) {
172 writeJSON(w, code, map[string]string{"error": msg})
173 }
174
internal/api/types.go --- Go
1 package api
2
3 // Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende
4 // Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben
5 // Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client.
6
7 // SpeakRequest ist der Body von POST /speak.
8 type SpeakRequest struct {
9 // Text ist der Ansage-Text (Pflicht).
10 Text string `json:"text"`
11
12 // Priority: low | normal | high (Default normal, wenn leer).
13 Priority string `json:"priority,omitempty"`
14
15 // Provider pinnt die Provider-Kette (--provider).
16 Provider string `json:"provider,omitempty"`
17
18 // Voice pinnt eine Voice-ID (--voice).
19 Voice string `json:"voice,omitempty"`
20
21 // Lang pinnt die Sprache de | en (leer = automatische Erkennung).
22 Lang string `json:"lang,omitempty"`
23
24 // Now lässt die Ansage vor allen wartenden springen (--now).
25 Now bool `json:"now,omitempty"`
26 }
27
28 // SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei
29 // aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes
30 // Verhalten, kein Fehler).
31 type SpeakResponse struct {
32 ID string `json:"id"`
33 Muted bool `json:"muted,omitempty"`
34 }
35
36 // CurrentItem beschreibt das laufende Item in GET /status.
37 type CurrentItem struct {
38 ID string `json:"id"`
39 Text string `json:"text"`
40 Priority string `json:"priority"`
41 Now bool `json:"now"`
42 }
43
44 // StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles
45 // Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version.
46 // QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge
47 // — die CLI braucht sie für ihr --wait-Polling.
48 type StatusResponse struct {
49 Version string `json:"version"`
50 Queue int `json:"queue"`
51 QueueIDs []string `json:"queue_ids,omitempty"`
52 Current *CurrentItem `json:"current"`
53 Muted bool `json:"muted"`
54 Provider string `json:"provider"`
55 }
56
57 // PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder
58 // gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage
59 // weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in
60 // beiden Fällen hat der Aufrufer nichts mehr zu warten.
61 func (s StatusResponse) PendingOrCurrent(id string) bool {
62 if s.Current != nil && s.Current.ID == id {
63 return true
64 }
65 for _, qid := range s.QueueIDs {
66 if qid == id {
67 return true
68 }
69 }
70 return false
71 }
72
73 // CancelResponse ist die Antwort von POST /cancel.
74 type CancelResponse struct {
75 Cancelled int `json:"cancelled"`
76 }
77
78 // MuteResponse ist die Antwort von POST /mute und POST /unmute.
79 type MuteResponse struct {
80 Muted bool `json:"muted"`
81 }
82
internal/config/config.go --- 1/5 --- Go
5 5 "fmt"
6 6 "os"
7 7 "path/filepath"
. 8 "strconv"
8 9
9 10 "github.com/BurntSushi/toml"
10 11 )
internal/config/config.go --- 2/5 --- Go
16 SinkNull = "null" 17 SinkNull = "null"
17 ) 18 )
.. 19
.. 20 // DefaultServerPort ist der Defaul
.. .. t-Port des Daemon-HTTP-Servers auf
.. 21 // 127.0.0.1 (Task-04-Brief: „Port
.. .. Default 7800").
.. 22 const DefaultServerPort = 7800
18 23
19 // Config ist der effektiv geladene 24 // Config ist der effektiv geladene
.. Konfigurations-Stand von talk. Tas .. Konfigurations-Stand von talk. Tas
.. k 02 .. k 02
20 // führt audio.sink; Task 03 ergänz 25 // führt audio.sink; Task 03 ergänz
.. t den Provider-Pin, Provider-Endpun .. t den Provider-Pin, Provider-Endpun
.. kte .. kte
21 // und das Voice-Mapping; spätere T 26 // und das Voice-Mapping; Task 04 d
.. asks ergänzen weitere Felder (serve .. en Daemon-Port (server.port, TALK_P
.. r, .. ORT);
22 // queue …), ohne die Auflösungsrei 27 // spätere Tasks ergänzen weitere F
.. henfolge zu ändern. .. elder (queue …), ohne die Auflösung
.. .. s-
.. 28 // reihenfolge zu ändern.
23 type Config struct { 29 type Config struct {
24 Audio Audio `toml:"audio"` 30 Audio Audio `toml:"audio"`
.. 31
.. 32 // Server steuert den Daemon-HT
.. .. TP-Server (talkd) auf 127.0.0.1.
.. 33 Server Server `toml:"server"`
25 34
26 // Provider pinnt die Kette auf 35 // Provider pinnt die Kette auf
.. genau einen Provider (z. B. .. genau einen Provider (z. B.
27 // `provider = "elevenlabs"`); 36 // `provider = "elevenlabs"`);
.. leer = komplette Default-Kette .. leer = komplette Default-Kette
internal/config/config.go --- 3/5 --- Go
48 57 Sink string `toml:"sink"`
49 58 }
.. 59
.. 60 // Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding
.. 61 // auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port).
.. 62 type Server struct {
.. 63 // Port des Daemon-HTTP-Servers auf 127.0.0.1 — config
.. 64 // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800.
.. 65 Port int `toml:"port"`
.. 66 }
50 67
51 68 // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und
52 69 // Modelle (TOML `[providers.<name>]`). Leer = eingebaute Defaults; die
internal/config/config.go --- 4/5 --- Go
63 80 // der Auflösung).
64 81 func Defaults() Config {
65 82 return Config{
.. 83 Audio: Audio{Sink: SinkOto},
.. 84 Server: Server{Port: DefaultServerPort},
.. 85 }
66 86 }
67 87
internal/config/config.go --- 5/5 --- Go
116 136 if v := getenv("TALK_PROVIDER"); v != "" {
117 137 cfg.Provider = v
118 138 }
... 139 if v := getenv("TALK_PORT"); v != "" {
... 140 port, err := strconv.Atoi(v)
... 141 if err != nil || port < 1 || port > 65535 {
... 142 return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v)
... 143 }
... 144 cfg.Server.Port = port
... 145 }
119 146 switch cfg.Audio.Sink {
120 147 case SinkOto, SinkNull:
121 148 return cfg, nil
internal/config/config_test.go --- Go
289 289 })
290 290 }
291
292 // TestLoadServerPort deckt die Task-04-Felder ab: Daemon-Port per Defaults
293 // (7800), TOML [server] port, Env-Override TALK_PORT samt Validierung.
294 func TestLoadServerPort(t *testing.T) {
295 writeTOML := func(t *testing.T, content string) string {
296 t.Helper()
297 path := filepath.Join(t.TempDir(), "config.toml")
298 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
299 t.Fatal(err)
300 }
301 return path
302 }
303
304 t.Run("Default 7800", func(t *testing.T) {
305 cfg, err := Load(Options{
306 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
307 Getenv: env(nil),
308 })
309 if err != nil {
310 t.Fatalf("Load: %v", err)
311 }
312 if cfg.Server.Port != DefaultServerPort {
313 t.Errorf("Server.Port = %d, want Default %d", cfg.Server.Port, DefaultServerPort)
314 }
315 })
316
317 t.Run("TOML überschreibt Default", func(t *testing.T) {
318 cfg, err := Load(Options{
319 File: writeTOML(t, "[server]\nport = 7999\n"),
320 Getenv: env(nil),
321 })
322 if err != nil {
323 t.Fatalf("Load: %v", err)
324 }
325 if cfg.Server.Port != 7999 {
326 t.Errorf("Server.Port = %d, want 7999 aus TOML", cfg.Server.Port)
327 }
328 })
329
330 t.Run("TALK_PORT schlägt Datei", func(t *testing.T) {
331 cfg, err := Load(Options{
332 File: writeTOML(t, "[server]\nport = 7999\n"),
333 Getenv: env(map[string]string{"TALK_PORT": "7912"}),
334 })
335 if err != nil {
336 t.Fatalf("Load: %v", err)
337 }
338 if cfg.Server.Port != 7912 {
339 t.Errorf("Server.Port = %d, want Env-Override 7912", cfg.Server.Port)
340 }
341 })
342
343 t.Run("ungültiger TALK_PORT-Wert ist klarer Fehler", func(t *testing.T) {
344 for _, bad := range []string{"null", "0", "-1", "70000"} {
345 _, err := Load(Options{
346 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"),
347 Getenv: env(map[string]string{"TALK_PORT": bad}),
348 })
349 if err == nil || !strings.Contains(err.Error(), "TALK_PORT") {
350 t.Fatalf("TALK_PORT=%q: Fehler = %v, will klaren TALK_PORT-Fehler", bad, err)
351 }
352 }
353 })
354 }
internal/queue/doc.go --- Go
1 // Package queue implementiert die An 1 // Package queue implementiert die An
. sage-Queue des Daemons: serialisierte . sage-Queue des Daemons: serialisierte
2 // Abarbeitung (eine Ansage gleichzei 2 // Abarbeitung (eine Ansage gleichzei
. tig), Prioritäten (high > normal > lo . tig), Prioritäten (high > normal > lo
. w, . w,
3 // FIFO je Level), now-Items springen 3 // FIFO je Level), now-Items springen
. vor, Rate-Limit mit prioritätsbewuss . vor, dazu den persistenten Mute-Stat
. tem . e
4 // Verwerfen. Implementierung folgt i 4 // (Datei mute im Config-Dir) und die
. n Task 04 (Queue) und Task 05 . Rate-Limit-Struktur. Das Rate-Limit
5 // (Rate-Limit, Ruhezeiten). 5 // ist als Struktur vorhanden, aber n
. . och nicht angebunden — Wiring und
. 6 // Ruhezeiten folgen in Task 05.
6 package queue 7 package queue
internal/queue/mute.go --- Go
1 package queue
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "strings"
8 )
9
10 // MuteFileName ist der Name der Mute-Datei im Config-Verzeichnis (bindender
11 // Vertrag: „persistenten Mute-State toggeln (Datei mute im Config-Dir)").
12 const MuteFileName = "mute"
13
14 // ErrNoMutePath meldet einen Set-Versuch ohne konfigurierten Datei-Pfad
15 // (Config-Dir nicht bestimmbar — der Daemon deaktiviert dann Mute mit einer
16 // Start-Warnung statt zu scheitern).
17 var ErrNoMutePath = errors.New("mute: kein Datei-Pfad konfiguriert (Config-Dir nicht bestimmbar)")
18
19 // MuteStore verwaltet den persistenten Mute-State als Datei im
20 // Config-Verzeichnis: Inhalt „true" = stumm, alles andere (oder keine Datei)
21 // = nicht stumm.
22 //
23 // Der Daemon liest die Datei bei jeder /speak-Anfrage neu — dadurch wirkt
24 // auch ein im Degraded Mode (Daemon tot) von der CLI direkt geschriebener
25 // State sofort, und der Zustand überlebt Daemon-Restarts.
26 type MuteStore struct {
27 // Path ist der vollständige Pfad der Mute-Datei. Leer (kein Config-Dir
28 // bestimmbar): Muted() ist immer false, Set ein Fehler.
29 Path string
30 }
31
32 // Muted meldet den persistenten Mute-State. Eine fehlende oder unlesbare
33 // Datei heißt: nicht stumm (Fehlstart ohne Config-Dir wird nie „stumm aus
34 // Versehen").
35 func (s MuteStore) Muted() bool {
36 if s.Path == "" {
37 return false
38 }
39 b, err := os.ReadFile(s.Path)
40 if err != nil {
41 return false
42 }
43 return strings.TrimSpace(string(b)) == "true"
44 }
45
46 // Set schreibt den Mute-State atomar (temporäre Datei + Rename, damit
47 // Daemon und CLI nie eine halb geschriebene Datei lesen) und legt das
48 // Config-Verzeichnis bei Bedarf an.
49 func (s MuteStore) Set(muted bool) error {
50 if s.Path == "" {
51 return ErrNoMutePath
52 }
53 dir := filepath.Dir(s.Path)
54 if err := os.MkdirAll(dir, 0o755); err != nil {
55 return err
56 }
57 content := "false\n"
58 if muted {
59 content = "true\n"
60 }
61 tmp, err := os.CreateTemp(dir, ".mute-*")
62 if err != nil {
63 return err
64 }
65 name := tmp.Name()
66 if _, err := tmp.WriteString(content); err != nil {
67 tmp.Close()
68 os.Remove(name)
69 return err
70 }
71 if err := tmp.Close(); err != nil {
72 os.Remove(name)
73 return err
74 }
75 if err := os.Chmod(name, 0o644); err != nil {
76 os.Remove(name)
77 return err
78 }
79 if err := os.Rename(name, s.Path); err != nil {
80 os.Remove(name)
81 return err
82 }
83 return nil
84 }
85
internal/queue/queue.go --- Go
1 package queue
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "sort"
11 "sync"
12 "time"
13 )
14
15 // Priority eines Queue-Items — öffentlicher Vertrag der HTTP-API
16 // (low | normal | high; leer wird als normal behandelt).
17 type Priority string
18
19 // Erlaubte Prioritäts-Werte (Spec: high > normal > low, FIFO je Level).
20 const (
21 PriorityLow Priority = "low"
22 PriorityNormal Priority = "normal"
23 PriorityHigh Priority = "high"
24 )
25
26 // Valid meldet, ob p ein erlaubter Prioritäts-Wert ist.
27 func (p Priority) Valid() bool {
28 switch p {
29 case PriorityLow, PriorityNormal, PriorityHigh:
30 return true
31 default:
32 return false
33 }
34 }
35
36 // ErrClosed meldet einen Enqueue-Versuch nach Close (Daemon fährt herunter).
37 var ErrClosed = errors.New("queue: bereits geschlossen")
38
39 // Item ist eine Ansage in der Warteschlange. Alle Felder kommen aus der
40 // HTTP-API (POST /speak); ID und Ablauf-Verwaltung liegen bei der Queue.
41 type Item struct {
42 // ID ist die kurze Ansage-Kennung (Antwort von POST /speak; Grundlage
43 // des --wait-Pollings der CLI). Wird von Enqueue vergeben, wenn leer.
44 ID string
45
46 // Text ist der Ansage-Text.
47 Text string
48
49 // Lang ist die Sprach-Pin (de | en); leer = automatische Erkennung
50 // durch den Daemon (langdetect).
51 Lang string
52
53 // Provider pinnt die Provider-Kette auf eine Stufe (--provider); leer
54 // = konfigurierte Default-Kette.
55 Provider string
56
57 // Voice pinnt eine Voice-ID (--voice); leer = Voice-Mapping/Defaults.
58 Voice string
59
60 // Priority steuert die Abarbeitungs-Reihenfolge (high > normal > low,
61 // FIFO je Level).
62 Priority Priority
63
64 // Now lässt die Ansage vor alle wartenden Items springen (auch vor
65 // high) — sie unterbricht aber nie die laufende Ansage.
66 Now bool
67
68 seq uint64 // Enqueue-Reihenfolge: FIFO-Tiebrecher innerhalb eines Levels
69 }
70
71 // rank liefert die Abarbeitungs-Klasse: now über high über normal über low.
72 func (it Item) rank() int {
73 if it.Now {
74 return 3
75 }
76 switch it.Priority {
77 case PriorityHigh:
78 return 2
79 case PriorityNormal:
80 return 1
81 default:
82 return 0
83 }
84 }
85
86 // Speaker spielt ein Item ab. Im Daemon: Provider-Kette bauen und Speak
87 // aufrufen (Synthese mit Fallback + Wiedergabe über den Audio-Sink — die
88 // Kette aus internal/provider wird unverändert wiederverwendet). ctx wird
89 // abgebrochen, wenn die Ansage abgebrochen (POST /cancel) oder der Daemon
90 // heruntergefahren wird; die Kette bricht ihre Netz- und Prozess-Arbeit
91 // dann ab. Der Rückgabe-Fehler wird protokolliert, nie an die API gereicht.
92 type Speaker func(ctx context.Context, item Item) error
93
94 // Queue ist die serialisierte Ansage-Queue des Daemons: genau eine Ansage
95 // gleichzeitig, Reihenfolge now > high > normal > low (FIFO je Level).
96 //
97 // Die Synchronisation ist bewusst simpel und offensichtlich korrekt statt
98 // clever: EIN Mutex + EIN Cond schützen alles (Items, aktuelles Item,
99 // Closed-Flag, cancelCur). Der Worker ist der einzige, der Items entnimmt;
100 // pop und das Setzen von current/cancelCur geschehen unter demselben Lock,
101 // damit ein Item nie zwischen „wartet" und „läuft" der Cancelierung
102 // entwischen kann. Cancel/Close rufen cancelCur unter dem Lock ab — der
103 // Worker bemerkt die Abbrechung in seiner speak-Funktion über ctx.
104 type Queue struct {
105 speak Speaker
106 log io.Writer
107
108 mu sync.Mutex
109 notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close
110 items []Item
111 current *Item
112 cancelCur context.CancelFunc
113 seq uint64
114 closed bool
115 done chan struct{} // geschlossen, wenn der Worker-Loop endet
116 }
117
118 // New startet die Queue mit ihrem Worker-Loop (eine Ansage gleichzeitig).
119 // log ist optional (nil = still) und empfängt die Lebenslauf-Zeilen der
120 // Items. Close beendet den Loop und wartet auf sein Ende.
121 func New(speak Speaker, log io.Writer) *Queue {
122 q := &Queue{speak: speak, log: log, done: make(chan struct{})}
123 q.notEmpty = sync.NewCond(&q.mu)
124 go q.loop()
125 return q
126 }
127
128 // Enqueue stellt item in die Queue und liefert dessen ID (bei leerer ID neu
129 // vergeben: kurz und zufällig). Nach Close ist Enqueue ein Fehler.
130 func (q *Queue) Enqueue(item Item) (string, error) {
131 q.mu.Lock()
132 if q.closed {
133 q.mu.Unlock()
134 return "", ErrClosed
135 }
136 if item.Priority == "" {
137 item.Priority = PriorityNormal
138 }
139 q.seq++
140 item.seq = q.seq
141 if item.ID == "" {
142 item.ID = newID()
143 }
144 q.items = append(q.items, item)
145 q.mu.Unlock()
146 q.notEmpty.Signal()
147 return item.ID, nil
148 }
149
150 // Pending liefert eine Momentaufnahme der wartenden Items in
151 // Abarbeitungs-Reihenfolge (bestes Item zuerst) — Basis für GET /status.
152 func (q *Queue) Pending() []Item {
153 q.mu.Lock()
154 defer q.mu.Unlock()
155 out := make([]Item, len(q.items))
156 copy(out, q.items)
157 sort.SliceStable(out, func(i, j int) bool { return itemBefore(out[i], out[j]) })
158 return out
159 }
160
161 // itemBefore meldet, ob a vor b abgearbeitet wird: höhere Klasse zuerst,
162 // innerhalb derselben Klasse die frühere Enqueue-Reihenfolge.
163 func itemBefore(a, b Item) bool {
164 if a.rank() != b.rank() {
165 return a.rank() > b.rank()
166 }
167 return a.seq < b.seq
168 }
169
170 // Current liefert das gerade laufende Item (false: Queue ist idle).
171 func (q *Queue) Current() (Item, bool) {
172 q.mu.Lock()
173 defer q.mu.Unlock()
174 if q.current == nil {
175 return Item{}, false
176 }
177 return *q.current, true
178 }
179
180 // Cancel bricht die laufende Ansage ab und leert die Queue. Rückgabe ist
181 // die Zahl der abgebrochenen Ansagen (laufende + wartende).
182 func (q *Queue) Cancel() int {
183 q.mu.Lock()
184 defer q.mu.Unlock()
185 n := len(q.items)
186 q.items = nil
187 if q.current != nil {
188 n++
189 if q.cancelCur != nil {
190 q.cancelCur()
191 }
192 }
193 return n
194 }
195
196 // Close fährt die Queue herunter: wartende Items werden verworfen, die
197 // laufende Ansage abgebrochen, der Worker-Loop beendet. Close wartet, bis
198 // der Loop sicher beendet ist (idempotent).
199 func (q *Queue) Close() {
200 q.mu.Lock()
201 if q.closed {
202 q.mu.Unlock()
203 <-q.done
204 return
205 }
206 q.closed = true
207 q.items = nil
208 if q.cancelCur != nil {
209 q.cancelCur()
210 }
211 q.mu.Unlock()
212 q.notEmpty.Broadcast()
213 <-q.done
214 }
215
216 // loop ist der Worker: wartet auf Items und spielt sie einzeln ab. Nach
217 // Close (oder wenn Close während einer Ansage zuschlägt) beendet er sich,
218 // sobald die laufende Ansage zurückgekehrt ist.
219 func (q *Queue) loop() {
220 defer close(q.done)
221 for {
222 q.mu.Lock()
223 for len(q.items) == 0 && !q.closed {
224 q.notEmpty.Wait()
225 }
226 if q.closed {
227 q.mu.Unlock()
228 return
229 }
230 item := q.popLocked()
231 ctx, cancel := context.WithCancel(context.Background())
232 q.current = &item
233 q.cancelCur = cancel
234 q.mu.Unlock()
235
236 q.logf("queue: ansage %s (%q, Priorität %s, now=%v) startet", item.ID, item.Text, item.Priority, item.Now)
237 start := time.Now()
238 err := q.speak(ctx, item)
239 dur := time.Since(start)
240 cancel()
241
242 q.mu.Lock()
243 q.current = nil
244 q.cancelCur = nil
245 q.mu.Unlock()
246
247 switch {
248 case ctx.Err() != nil:
249 q.logf("queue: ansage %s abgebrochen", item.ID)
250 case err != nil:
251 q.logf("queue: ansage %s fehlgeschlagen nach %s: %v", item.ID, dur.Round(time.Millisecond), err)
252 default:
253 q.logf("queue: ansage %s fertig nach %s", item.ID, dur.Round(time.Millisecond))
254 }
255 }
256 }
257
258 // popLocked entnimmt das als nächstes fällige Item (mu muss gehalten werden).
259 func (q *Queue) popLocked() Item {
260 best := 0
261 for i := 1; i < len(q.items); i++ {
262 if itemBefore(q.items[i], q.items[best]) {
263 best = i
264 }
265 }
266 item := q.items[best]
267 q.items = append(q.items[:best], q.items[best+1:]...)
268 return item
269 }
270
271 // newID liefert eine kurze, kollisionsarme ID (6 Hex-Zeichen). IDs müssen
272 // nur pro Daemon-Lauf eindeutig sein (--wait-Polling); der Fallback ist
273 // praktisch unerreichbar, aber deterministisch sicher.
274 func newID() string {
275 var b [3]byte
276 if _, err := rand.Read(b[:]); err == nil {
277 return hex.EncodeToString(b[:])
278 }
279 return fmt.Sprintf("id%d", time.Now().UnixNano())
280 }
281
282 // logf schreibt eine Protokoll-Zeile, wenn ein Log gesetzt ist. Wird nie
283 // unter dem Mutex gerufen (Aufrufer achten darauf).
284 func (q *Queue) logf(format string, args ...any) {
285 if q.log != nil {
286 fmt.Fprintf(q.log, format+"\n", args...)
287 }
288 }
289
internal/queue/queue_test.go --- Go
1 package queue
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "slices"
8 "testing"
9 "time"
10 )
11
12 // gesteuerteSpielerin ist ein testbarer Speaker: Jede Ansage meldet ihren
13 // Start auf started (Kanal-Puffer: der Worker blockiert nie) und wartet
14 // dann, bis der Test sie per step freigibt — so ist die Abarbeitung
15 // vollständig deterministisch steuerbar. Ein abgebrochener ctx beendet das
16 // Warten (Cancel-/Close-Pfade). Alle Felder werden nur vom Test gelesen;
17 // started-Reihenfolge == Start-Reihenfolge.
18 type gesteuerteSpielerin struct {
19 started chan Item
20 step chan struct{}
21 consumed int // von warteBis konsumierte Starts (nur Test-Goroutine)
22 }
23
24 func neueSpielerin() *gesteuerteSpielerin {
25 return &gesteuerteSpielerin{started: make(chan Item, 16), step: make(chan struct{})}
26 }
27
28 func (g *gesteuerteSpielerin) speak(ctx context.Context, item Item) error {
29 g.started <- item
30 select {
31 case <-g.step:
32 return nil
33 case <-ctx.Done():
34 return ctx.Err()
35 }
36 }
37
38 // warteBis wartet, bis insgesamt n Ansagen gestartet sind, und liefert die
39 // seit dem letzten warteBis-Aufruf neu gestarteten.
40 func (g *gesteuerteSpielerin) warteBis(t *testing.T, n int) []Item {
41 t.Helper()
42 var items []Item
43 deadline := time.After(2 * time.Second)
44 for g.consumed < n {
45 select {
46 case it := <-g.started:
47 g.consumed++
48 items = append(items, it)
49 case <-deadline:
50 t.Fatalf("nur %d von %d Ansagen gestartet", g.consumed, n)
51 }
52 }
53 return items
54 }
55
56 // keineWeitere prüft, dass keine weitere Ansage startet (Poll-Fenster).
57 func (g *gesteuerteSpielerin) keineWeitere(t *testing.T) {
58 t.Helper()
59 select {
60 case it := <-g.started:
61 g.consumed++
62 t.Fatalf("unerwartete Ansage %q gestartet", it.Text)
63 case <-time.After(150 * time.Millisecond):
64 }
65 }
66
67 // TestAbarbeitungsReihenfolge: now vor high vor normal vor low, FIFO je
68 // Level. Der erste Blocker besetzt den Worker; danach werden alle Level
69 // gleichzeitig eingereiht — die Abarbeitung muss der Klasse folgen.
70 func TestAbarbeitungsReihenfolge(t *testing.T) {
71 g := neueSpielerin()
72 q := New(g.speak, nil)
73 defer q.Close()
74
75 erster, err := q.Enqueue(Item{Text: "blocker", Priority: PriorityNormal})
76 if err != nil {
77 t.Fatal(err)
78 }
79 if erster == "" {
80 t.Fatal("Enqueue vergibt keine ID")
81 }
82 g.warteBis(t, 1) // Blocker läuft, Worker gebunden.
83
84 for _, it := range []Item{
85 {Text: "niedrig", Priority: PriorityLow},
86 {Text: "zweit-niedrig", Priority: PriorityLow},
87 {Text: "mittel", Priority: PriorityNormal},
88 {Text: "dringend", Priority: PriorityHigh},
89 {Text: "sofort", Now: true},
90 } {
91 if _, err := q.Enqueue(it); err != nil {
92 t.Fatal(err)
93 }
94 }
95 g.keineWeitere(t) // Serialisierung: Worker spielt noch den Blocker.
96
97 // Freigeben: alles Übrige läuft in Klassen-Reihenfolge durch.
98 close(g.step)
99 rest := g.warteBis(t, 6)
100
101 var texts []string
102 for _, it := range rest {
103 texts = append(texts, it.Text)
104 }
105 want := []string{"sofort", "dringend", "mittel", "niedrig", "zweit-niedrig"}
106 if !slices.Equal(texts, want) {
107 t.Errorf("Abarbeitung nach dem Blocker = %v, want %v", texts, want)
108 }
109 }
110
111 // TestCurrentUndPending: Status-Sicht auf laufendes Item + Warteschlange.
112 func TestCurrentUndPending(t *testing.T) {
113 g := neueSpielerin()
114 q := New(g.speak, nil)
115 defer q.Close()
116
117 if _, ok := q.Current(); ok {
118 t.Fatal("Current vor erster Ansage: want idle")
119 }
120
121 q.Enqueue(Item{Text: "blocker"})
122 g.warteBis(t, 1)
123
124 q.Enqueue(Item{Text: "hoch", Priority: PriorityHigh})
125 q.Enqueue(Item{Text: "niedrig", Priority: PriorityLow})
126
127 if cur, ok := q.Current(); !ok || cur.Text != "blocker" {
128 t.Errorf("Current = %+v (ok=%v), will Blocker", cur, ok)
129 }
130 var pendingTexts []string
131 for _, it := range q.Pending() {
132 pendingTexts = append(pendingTexts, it.Text)
133 }
134 if !slices.Equal(pendingTexts, []string{"hoch", "niedrig"}) {
135 t.Errorf("Pending = %v, want [hoch niedrig]", pendingTexts)
136 }
137
138 close(g.step)
139 g.warteBis(t, 3)
140 }
141
142 // TestCancel: laufende Ansage wird über ctx abgebrochen, Queue geleert,
143 // Gesamtzahl zurückgemeldet; danach ist die Queue wieder aufnahme- und
144 // spielbereit.
145 func TestCancel(t *testing.T) {
146 g := neueSpielerin()
147 q := New(g.speak, nil)
148 defer q.Close()
149
150 q.Enqueue(Item{Text: "blocker"})
151 g.warteBis(t, 1)
152
153 q.Enqueue(Item{Text: "wartet-eins"})
154 q.Enqueue(Item{Text: "wartet-zwei"})
155
156 if n := q.Cancel(); n != 3 {
157 t.Errorf("Cancel = %d, want 3 (laufende + 2 wartende)", n)
158 }
159
160 g.keineWeitere(t) // Blocker durch ctx beendet, wartende Items sind weg.
161 waitIdle(t, q)
162
163 if n := q.Cancel(); n != 0 {
164 t.Errorf("Cancel auf leerer Queue = %d, want 0", n)
165 }
166
167 // Queue nimmt wieder an und spielt:
168 id, err := q.Enqueue(Item{Text: "danach"})
169 if err != nil {
170 t.Fatal(err)
171 }
172 g.step <- struct{}{} // gibt die wartende Ansage im Speaker frei
173 gestartet := g.warteBis(t, 2)
174 if len(gestartet) != 1 || gestartet[0].ID != id {
175 t.Errorf("neue Ansage nicht gestartet: %+v", gestartet)
176 }
177 }
178
179 // TestClose: wartende Items werden verworfen, die laufende Ansage über den
180 // abgebrochenen ctx beendet, Close wartet auf den Worker-Exit, danach ist
181 // Enqueue ein Fehler (und Close idempotent).
182 func TestClose(t *testing.T) {
183 g := neueSpielerin()
184 q := New(g.speak, nil)
185
186 q.Enqueue(Item{Text: "blocker"})
187 g.warteBis(t, 1)
188 q.Enqueue(Item{Text: "wartet"})
189
190 q.Close()
191
192 g.keineWeitere(t)
193 if _, ok := q.Current(); ok {
194 t.Error("Current nach Close: want idle")
195 }
196 if _, err := q.Enqueue(Item{Text: "zu spät"}); err == nil {
197 t.Error("Enqueue nach Close: want Fehler")
198 }
199 q.Close() // idempotent
200 }
201
202 // TestEnqueueVergibtIDs: IDs sind kurz und eindeutig.
203 func TestEnqueueVergibtIDs(t *testing.T) {
204 g := neueSpielerin()
205 q := New(g.speak, nil)
206 defer q.Close()
207
208 seen := map[string]bool{}
209 for i := 0; i < 20; i++ {
210 id, err := q.Enqueue(Item{Text: "x"})
211 if err != nil {
212 t.Fatal(err)
213 }
214 if len(id) < 4 || seen[id] {
215 t.Errorf("ID %q zu kurz oder doppelt", id)
216 }
217 seen[id] = true
218 }
219 }
220
221 // waitIdle pollt, bis kein Item mehr läuft (Cancel-/Close-Pfade geben das
222 // nach dem Worker-Exit synchron frei; das Pollen deckt die Übergangsphase
223 // ab, ohne auf Timing zu trauen).
224 func waitIdle(t *testing.T, q *Queue) {
225 t.Helper()
226 deadline := time.Now().Add(time.Second)
227 for {
228 if _, ok := q.Current(); !ok {
229 return
230 }
231 if time.Now().After(deadline) {
232 t.Fatal("Queue wird nicht idle")
233 }
234 time.Sleep(5 * time.Millisecond)
235 }
236 }
237
238 // TestMuteStore: Datei-Roundtrip, Fehlend-Heißt-Nicht-Stumm, atomares Set.
239 func TestMuteStore(t *testing.T) {
240 dir := t.TempDir()
241 path := filepath.Join(dir, MuteFileName)
242
243 var s MuteStore
244 if s.Muted() {
245 t.Error("MuteStore ohne Pfad: Muted() = true, want false")
246 }
247 if err := s.Set(true); err == nil {
248 t.Error("Set ohne Pfad: want Fehler")
249 }
250
251 s = MuteStore{Path: path}
252 if s.Muted() {
253 t.Error("ohne Datei: Muted() = true, want false (nie still aus Versehen)")
254 }
255
256 if err := s.Set(true); err != nil {
257 t.Fatal(err)
258 }
259 if !s.Muted() {
260 t.Error("nach Set(true): Muted() = false")
261 }
262 if b, _ := os.ReadFile(path); string(b) != "true\n" {
263 t.Errorf("Datei-Inhalt = %q, want %q", b, "true\n")
264 }
265
266 if err := s.Set(false); err != nil {
267 t.Fatal(err)
268 }
269 if s.Muted() {
270 t.Error("nach Set(false): Muted() = true")
271 }
272
273 // Müll in der Datei heißt: nicht stumm (nur „true" ist stumm).
274 if err := os.WriteFile(path, []byte("vielleicht\n"), 0o644); err != nil {
275 t.Fatal(err)
276 }
277 if s.Muted() {
278 t.Error("Datei-Inhalt „vielleicht“: Muted() = true, want false")
279 }
280 }
281
282 // TestRateLimiter: Struktur-Semantik (Task 05 verdrahtet sie in den Loop).
283 func TestRateLimiter(t *testing.T) {
284 // Unbegrenzt (Task-04-Default): Interval 0 erlaubt immer.
285 unlimited := &RateLimiter{}
286 for i := 0; i < 3; i++ {
287 if !unlimited.Allow() {
288 t.Fatal("Interval 0: Allow = false, want true (unbegrenzt)")
289 }
290 unlimited.Reserve()
291 }
292
293 // Begrenzt: nach Reserve ist erst nach Interval wieder Allow.
294 l := &RateLimiter{Interval: 30 * time.Millisecond}
295 if !l.Allow() {
296 t.Fatal("frisches Limit: Allow = false")
297 }
298 l.Reserve()
299 if l.Allow() {
300 t.Fatal("sofort nach Reserve: Allow = true, want false")
301 }
302 if next := l.Next(); time.Now().After(next) {
303 t.Errorf("Next() = %v liegt in der Vergangenheit", next)
304 }
305 time.Sleep(40 * time.Millisecond)
306 if !l.Allow() {
307 t.Fatal("nach Interval: Allow = false, want true")
308 }
309 }
310
internal/queue/rate.go --- Go
1 package queue
2
3 import (
4 "sync"
5 "time"
6 )
7
8 // RateLimiter ist die Struktur für das Daemon-Gesetz gegen Ansage-Spam
9 // (Spec: Default ca. 1 Ansage / 30 s, konfigurierbar).
10 //
11 // SEAM FÜR TASK 05: Struktur und Semantik stehen und sind getestet, sind
12 // aber noch NICHT an den Worker-Loop angebunden — Task 04 erzeugt keinen
13 // Limiter (unbegrenzt, so verlangt es der Task-04-Brief: „Default unendlich
14 // in T04-Tests, dokumentiert"). Task 05 verdrahtet den Limiter (config
15 // queue.rate_limit) in den Loop und ergänzt die prioritätsbewusste
16 // Entscheidung: low verwerfen, normal parken, high konfigurierbar verstoßen
17 // (queue.allow_high_burst) — dafür ist Next() bereits Teil der Struktur.
18 type RateLimiter struct {
19 // Interval ist der Mindestabstand zwischen zwei Ansage-Starts.
20 // 0 oder negativ = unbegrenzt (Task-04-Default).
21 Interval time.Duration
22
23 mu sync.Mutex
24 last time.Time // Start der zuletzt per Reserve gebuchten Ansage
25 }
26
27 // Allow meldet, ob sofort eine Ansage starten dürfte.
28 func (l *RateLimiter) Allow() bool {
29 if l == nil || l.Interval <= 0 {
30 return true
31 }
32 l.mu.Lock()
33 defer l.mu.Unlock()
34 return time.Since(l.last) >= l.Interval
35 }
36
37 // Reserve bucht einen Ansage-Slot (direkt nach Allow aufrufen).
38 func (l *RateLimiter) Reserve() {
39 if l == nil || l.Interval <= 0 {
40 return
41 }
42 l.mu.Lock()
43 defer l.mu.Unlock()
44 l.last = time.Now()
45 }
46
47 // Next liefert, wann der nächste Ansage-Slot frei wird (Nullzeit, wenn
48 // Allow sofort true liefern würde) — Basis für das Parken wartender Items
49 // in Task 05.
50 func (l *RateLimiter) Next() time.Time {
51 if l == nil || l.Interval <= 0 {
52 return time.Time{}
53 }
54 l.mu.Lock()
55 defer l.mu.Unlock()
56 return l.last.Add(l.Interval)
57 }
58
test/cli_test.go --- Go
1 package test
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10 )
11
12 // TestSayNonBlocking (Abnahmekriterium non-blocking): `talk say` kehrt
13 // deutlich vor Ende der Ansage zurück (Exit 0, Item-ID auf stdout), die
14 // Ansage läuft im Daemon weiter zu Ende.
15 func TestSayNonBlocking(t *testing.T) {
16 root := t.TempDir()
17 fake := newFakeProvider(t, 600*time.Millisecond)
18 d := startDaemon(t, root, fake.srv.URL, nil)
19 env := daemonEnv(root, d.port, fake.srv.URL, nil)
20
21 start := time.Now()
22 res := runTalk(t, env, "say", "langlaufende ansage")
23 elapsed := time.Since(start)
24
25 if res.code != 0 {
26 t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
27 }
28 if elapsed >= 500*time.Millisecond {
29 t.Fatalf("talk say war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
30 }
31 id := strings.TrimSpace(res.stdout)
32 if id == "" || strings.Contains(id, "\n") {
33 t.Errorf("stdout = %q, will genau eine Zeile mit der Item-ID", res.stdout)
34 }
35
36 // Die Ansage läuft trotzdem zu Ende (Daemon besitzt sie jetzt).
37 hits := fake.waitHits(t, 1)
38 if hits[0].Text != "langlaufende ansage" {
39 t.Errorf("Treffer = %q", hits[0].Text)
40 }
41 waitStatusIdle(t, d, 3*time.Second)
42 }
43
44 // TestSayWait: `talk say --wait` kehrt erst nach Ende der Ansage zurück.
45 func TestSayWait(t *testing.T) {
46 root := t.TempDir()
47 fake := newFakeProvider(t, 400*time.Millisecond)
48 d := startDaemon(t, root, fake.srv.URL, nil)
49 env := daemonEnv(root, d.port, fake.srv.URL, nil)
50
51 start := time.Now()
52 res := runTalk(t, env, "say", "--wait", "warten auf mich")
53 elapsed := time.Since(start)
54
55 if res.code != 0 {
56 t.Fatalf("talk say --wait: Exit %d, stderr: %s", res.code, res.stderr)
57 }
58 if elapsed < 350*time.Millisecond {
59 t.Fatalf("talk say --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
60 }
61 if hits := fake.waitHits(t, 1); hits[0].Text != "warten auf mich" {
62 t.Errorf("Treffer = %q", hits[0].Text)
63 }
64 waitStatusIdle(t, d, 3*time.Second)
65 }
66
67 // TestSayDegradedMode (Abnahmekriterium Degraded Mode): Daemon tot →
68 // Lock-Datei im Config-Dir, sequenzielles lokales Abspielen (hier: direkt
69 // gegen den Fake-Provider), Lock danach wieder freigegeben.
70 func TestSayDegradedMode(t *testing.T) {
71 root := t.TempDir()
72 fake := newFakeProvider(t, 0)
73 env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
74
75 res := runTalk(t, env, "say", "offline ansage")
76 if res.code != 0 {
77 t.Fatalf("talk say (Degraded): Exit %d, stderr: %s", res.code, res.stderr)
78 }
79 if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
80 t.Errorf("Treffer = %q", hits[0].Text)
81 }
82 if !strings.Contains(res.stderr, "Degraded Mode") {
83 t.Errorf("stderr = %q, will Degraded-Hinweis (nichts bricht still)", res.stderr)
84 }
85
86 // Lock-Datei wurde nach dem Abspielen freigegeben.
87 lockPath := filepath.Join(configDir(root), "talk.lock")
88 if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
89 t.Errorf("Lock-Datei %s nicht freigegeben (err=%v)", lockPath, err)
90 }
91 }
92
93 // TestSayDegradedSerialisiertUeberLock: zwei gleichzeitige talk-Prozesse
94 // ohne Daemon spielen nacheinander (Kollision = warten), nicht überlappend.
95 func TestSayDegradedSerialisiertUeberLock(t *testing.T) {
96 root := t.TempDir()
97 fake := newFakeProvider(t, 250*time.Millisecond)
98 env := daemonEnv(root, 1, fake.srv.URL, nil)
99
100 var wg sync.WaitGroup
101 results := make([]talkResult, 2)
102 for i, text := range []string{"eins", "zwei"} {
103 wg.Add(1)
104 go func(i int, text string) {
105 defer wg.Done()
106 results[i] = runTalk(t, env, "say", text)
107 }(i, text)
108 }
109 wg.Wait()
110
111 for i, res := range results {
112 if res.code != 0 {
113 t.Fatalf("talk %d (Degraded, parallel): Exit %d, stderr: %s", i, res.code, res.stderr)
114 }
115 }
116
117 // Reihenfolge offen (wer zuerst das Lock nimmt), aber serialisiert:
118 // die zweite Synthese startet erst nach der Antwort der ersten.
119 hits := fake.waitHits(t, 2)
120 const slack = 25 * time.Millisecond
121 if hits[1].Start.Before(hits[0].End.Add(-slack)) {
122 t.Errorf("Degraded-Ansagen überlappten: Start(2)=%v < Ende(1)=%v — Lock-Serialisierung kaputt", hits[1].Start, hits[0].End)
123 }
124 }
125
126 // TestSayDegradedRespektiertMute: ohne Daemon verwirft der Degraded Mode
127 // Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
128 func TestSayDegradedRespektiertMute(t *testing.T) {
129 root := t.TempDir()
130 fake := newFakeProvider(t, 0)
131 env := daemonEnv(root, 1, fake.srv.URL, nil)
132
133 // Mute-Datei direkt ins Config-Dir (Zustand „Daemon war mal da").
134 if err := os.MkdirAll(configDir(root), 0o755); err != nil {
135 t.Fatal(err)
136 }
137 if err := os.WriteFile(filepath.Join(configDir(root), "mute"), []byte("true\n"), 0o644); err != nil {
138 t.Fatal(err)
139 }
140
141 res := runTalk(t, env, "say", "sollte stumm bleiben")
142 if res.code != 0 {
143 t.Fatalf("talk say (mute an): Exit %d, stderr: %s", res.code, res.stderr)
144 }
145 if res.stdout != "muted\n" {
146 t.Errorf("stdout = %q, want muted", res.stdout)
147 }
148 fake.assertNoHits(t, 300*time.Millisecond)
149
150 // Unmute (offline) → spielt wieder.
151 res = runTalk(t, env, "unmute")
152 if res.code != 0 {
153 t.Fatalf("talk unmute (offline): Exit %d, stderr: %s", res.code, res.stderr)
154 }
155 res = runTalk(t, env, "say", "jetzt wieder")
156 if res.code != 0 {
157 t.Fatalf("talk say (mute aus): Exit %d, stderr: %s", res.code, res.stderr)
158 }
159 if hits := fake.waitHits(t, 1); hits[0].Text != "jetzt wieder" {
160 t.Errorf("Treffer = %q", hits[0].Text)
161 }
162 }
163
164 // TestCliMuteUnmuteStatusCancel: die Steuer-Befehle als Daemon-Aufrufe mit
165 // plain Output — mute/unmute als Token, status als Block, cancel mit Zahl.
166 func TestCliMuteUnmuteStatusCancel(t *testing.T) {
167 root := t.TempDir()
168 fake := newFakeProvider(t, 0)
169 d := startDaemon(t, root, fake.srv.URL, nil)
170 env := daemonEnv(root, d.port, fake.srv.URL, nil)
171
172 res := runTalk(t, env, "mute")
173 if res.code != 0 || res.stdout != "muted\n" {
174 t.Fatalf("talk mute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
175 }
176
177 // say bei Mute: verworfen, plain gemeldet, kein Provider-Kontakt.
178 res = runTalk(t, env, "say", "stille")
179 if res.code != 0 || res.stdout != "muted\n" {
180 t.Fatalf("talk say (mute an): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
181 }
182 fake.assertNoHits(t, 300*time.Millisecond)
183
184 res = runTalk(t, env, "status")
185 if res.code != 0 {
186 t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
187 }
188 for _, want := range []string{"version dev", "queue: 0", "mute: an", "provider: elevenlabs"} {
189 if !strings.Contains(res.stdout, want) {
190 t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
191 }
192 }
193
194 res = runTalk(t, env, "unmute")
195 if res.code != 0 || res.stdout != "unmuted\n" {
196 t.Fatalf("talk unmute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
197 }
198
199 res = runTalk(t, env, "cancel")
200 if res.code != 0 || res.stdout != "cancelled: 0\n" {
201 t.Fatalf("talk cancel (leer): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
202 }
203
204 res = runTalk(t, env, "status")
205 if !strings.Contains(res.stdout, "mute: aus") {
206 t.Errorf("talk status nach unmute = %q, will mute aus", res.stdout)
207 }
208 }
209
210 // TestCliCancelMitQueue: cancel über die CLI bricht laufende + wartende
211 // Ansage ab und meldet die Zahl.
212 func TestCliCancelMitQueue(t *testing.T) {
213 root := t.TempDir()
214 fake := newFakeProvider(t, 5*time.Second)
215 d := startDaemon(t, root, fake.srv.URL, nil)
216 env := daemonEnv(root, d.port, fake.srv.URL, nil)
217
218 speak(t, d, `{"text":"blocker"}`)
219 fake.waitArrived(t, 1)
220 runTalk(t, env, "say", "wartet auf cancel") // non-blocking enqueue
221
222 res := runTalk(t, env, "cancel")
223 if res.code != 0 || res.stdout != "cancelled: 2\n" {
224 t.Fatalf("talk cancel: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
225 }
226
227 fake.setDelay(50 * time.Millisecond)
228 waitStatusIdle(t, d, 2*time.Second)
229 speak(t, d, `{"text":"danach"}`)
230 hits := fake.waitCompleted(t, 1)
231 if hits[0].Text != "danach" {
232 t.Errorf("Treffer = %q, want danach", hits[0].Text)
233 }
234 }
235
236 // TestDreiParalleleTalkAufrufe (Abnahmekriterium Task 04): `talkd &` +
237 // drei parallele `talk`-Aufrufe → drei Ansagen nacheinander. Die Enqueue-
238 // Reihenfolge der parallelen Prozesse ist offen — geprüft wird, dass alle
239 // drei abgespielt werden und KEINE zwei überlappen.
240 func TestDreiParalleleTalkAufrufe(t *testing.T) {
241 root := t.TempDir()
242 fake := newFakeProvider(t, 200*time.Millisecond)
243 d := startDaemon(t, root, fake.srv.URL, nil)
244 env := daemonEnv(root, d.port, fake.srv.URL, nil)
245
246 results := make([]talkResult, 3)
247 var wg sync.WaitGroup
248 for i, text := range []string{"erste", "zweite", "dritte"} {
249 wg.Add(1)
250 go func(i int, text string) {
251 defer wg.Done()
252 results[i] = runTalk(t, env, "say", text)
253 }(i, text)
254 }
255 wg.Wait()
256 for i, res := range results {
257 if res.code != 0 {
258 t.Fatalf("paralleler talk-Aufruf %d: Exit %d — stderr: %s", i, res.code, res.stderr)
259 }
260 }
261
262 hits := fake.waitCompleted(t, 3)
263 played := map[string]bool{}
264 for _, h := range hits {
265 played[h.Text] = true
266 }
267 for _, text := range []string{"erste", "zweite", "dritte"} {
268 if !played[text] {
269 t.Errorf("Ansage %q fehlt (Treffer: %+v)", text, hits)
270 }
271 }
272 const slack = 25 * time.Millisecond
273 for i := 1; i < len(hits); i++ {
274 if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
275 t.Errorf("Ansagen %d/%d überlappten: Start %v < Ende %v", i-1, i, hits[i].Start, hits[i-1].End)
276 }
277 }
278 waitStatusIdle(t, d, 3*time.Second)
279 }
280
281 // TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
282 // Fehler (kein Stillversagen) — say fällt dagegen in den Degraded Mode.
283 func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
284 root := t.TempDir()
285 fake := newFakeProvider(t, 0)
286 env := daemonEnv(root, 1, fake.srv.URL, nil)
287
288 if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "nicht erreichbar") {
289 t.Errorf("talk status ohne Daemon: %d %q", res.code, res.stderr)
290 }
291 if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
292 t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
293 }
294 }
295
296 // TestSayDefaultPfadWieSay: `talk "Text"` nimmt denselben Daemon-Pfad wie
297 // `talk say "Text"` (Flags vor und nach dem Text).
298 func TestSayDefaultPfadWieSay(t *testing.T) {
299 root := t.TempDir()
300 fake := newFakeProvider(t, 0)
301 d := startDaemon(t, root, fake.srv.URL, nil)
302 env := daemonEnv(root, d.port, fake.srv.URL, nil)
303
304 res := runTalk(t, env, "default aufruf")
305 if res.code != 0 {
306 t.Fatalf("talk \"Text\": Exit %d — stderr: %s", res.code, res.stderr)
307 }
308 if id := strings.TrimSpace(res.stdout); id == "" {
309 t.Errorf("stdout = %q, will Item-ID", res.stdout)
310 }
311
312 // Flag nach dem Text (parseInterspersed) auf dem Daemon-Pfad.
313 res = runTalk(t, env, "noch ein text", "--lang=en")
314 if res.code != 0 {
315 t.Fatalf("talk Text --lang=en: Exit %d — stderr: %s", res.code, res.stderr)
316 }
317
318 hits := fake.waitHits(t, 2)
319 if !strings.Contains(hits[1].Text, "noch ein text") {
320 t.Errorf("zweiter Treffer = %q", hits[1].Text)
321 }
322 waitStatusIdle(t, d, 3*time.Second)
323 }
324
test/harness_test.go --- Go
1 // Package test enthält die Prozessgrenzen-Tests von talk 2.0 — die primäre
2 // Test-Seam laut Spec: talkd und talk laufen als echte Prozesse (gebuildet
3 // aus diesem Repo), angetrieben über HTTP und die CLI, mit zwei injizierten
4 // Fakes: einem ElevenLabs-Fake-Endpunkt (httptest-Server mit Audio-Bytes)
5 // und audio.sink=null. Getestet wird nur extern beobachtbares Verhalten:
6 // HTTP-Antworten, CLI-Exit-Codes/-Output, Fake-Treffer (Reihenfolge und
7 // Zeit), Dateien im Config-Dir. Tests binden NIEMALS den Default-Port 7800
8 // und hängen nie von einem echten Daemon ab — talkd bekommt einen freien
9 // Port per TALK_PORT, CLI-Prozesse denselben (oder Port 1 für „Daemon tot").
10 package test
11
12 import (
13 "bytes"
14 "encoding/json"
15 "fmt"
16 "net"
17 "net/http"
18 "net/http/httptest"
19 "os"
20 "os/exec"
21 "path/filepath"
22 "strconv"
23 "strings"
24 "sync"
25 "testing"
26 "time"
27 )
28
29 // Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
30 // billig) und von allen Tests geteilt.
31 var (
32 talkBin string
33 talkdBin string
34 )
35
36 func TestMain(m *testing.M) {
37 dir, err := os.MkdirTemp("", "talk-bins-*")
38 if err != nil {
39 fmt.Fprintln(os.Stderr, err)
40 os.Exit(1)
41 }
42 repo, err := filepath.Abs("..")
43 if err != nil {
44 fmt.Fprintln(os.Stderr, err)
45 os.Exit(1)
46 }
47 for _, b := range []struct{ out, pkg string }{
48 {"talk", "./cmd/talk"},
49 {"talkd", "./cmd/talkd"},
50 } {
51 cmd := exec.Command("go", "build", "-o", filepath.Join(dir, b.out), b.pkg)
52 cmd.Dir = repo
53 if out, err := cmd.CombinedOutput(); err != nil {
54 fmt.Fprintf(os.Stderr, "go build %s: %v\n%s", b.pkg, err, out)
55 os.RemoveAll(dir)
56 os.Exit(1)
57 }
58 }
59 talkBin, talkdBin = filepath.Join(dir, "talk"), filepath.Join(dir, "talkd")
60
61 code := m.Run()
62 os.RemoveAll(dir)
63 os.Exit(code)
64 }
65
66 // syncBuffer ist ein nebenläufigkeitssicherer Buffer (Daemon-stderr wird
67 // gelesen, während der Prozess läuft).
68 type syncBuffer struct {
69 mu sync.Mutex
70 buf bytes.Buffer
71 }
72
73 func (b *syncBuffer) Write(p []byte) (int, error) {
74 b.mu.Lock()
75 defer b.mu.Unlock()
76 return b.buf.Write(p)
77 }
78
79 func (b *syncBuffer) String() string {
80 b.mu.Lock()
81 defer b.mu.Unlock()
82 return b.buf.String()
83 }
84
85 // fakeHit ist ein beobachteter Synthese-Request am Fake-Provider.
86 type fakeHit struct {
87 Text string // Text aus dem Request-Body
88 Start time.Time // Ankunft am Fake
89 End time.Time // Antwort abgeschickt bzw. Abbruch gesehen
90 Completed bool // true: Antwort geschrieben; false: Client hat abgebrochen
91 }
92
93 // fakeProvider ist ein ElevenLabs-Fake (POST /v1/text-to-speech/<voice>):
94 // Er antwortet mit ein paar Bytes „Audio" und protokolliert jeden Request
95 // mit Ankunfts- und Antwort-Zeit — daraus sind Reihenfolge (Textfolge) und
96 // Serialisierung (Ankunft des nächsten nach Antwort des vorigen) ablesbar.
97 // delay ist zur Laufzeit umschaltbar, um blockierende Synthesen zu
98 // simulieren (Serialisierung, Priorität, cancel). Ankünfte werden separat
99 // und sofort protokolliert: Sie zeigen, dass der Daemon mit einer Synthese
100 // BESCHÄFTIGT ist (Queue deterministisch befüllbar), noch bevor die
101 // (verzögerte) Antwort als Treffer landet.
102 type fakeProvider struct {
103 srv *httptest.Server
104 mu sync.Mutex
105 hits []fakeHit
106 arrived []string
107 delay time.Duration
108 }
109
110 func newFakeProvider(t *testing.T, delay time.Duration) *fakeProvider {
111 t.Helper()
112 f := &fakeProvider{delay: delay}
113 f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
114 t.Cleanup(f.srv.Close)
115 return f
116 }
117
118 func (f *fakeProvider) serve(w http.ResponseWriter, r *http.Request) {
119 var body struct {
120 Text string `json:"text"`
121 }
122 start := time.Now()
123 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
124 http.Error(w, err.Error(), http.StatusBadRequest)
125 return
126 }
127 f.mu.Lock()
128 f.arrived = append(f.arrived, body.Text)
129 d := f.delay
130 f.mu.Unlock()
131 if d > 0 {
132 select {
133 case <-time.After(d):
134 case <-r.Context().Done(): // Client (Daemon) hat abgebrochen
135 f.record(fakeHit{Text: body.Text, Start: start, End: time.Now()})
136 return
137 }
138 }
139 _, _ = w.Write([]byte("\xff\xfb\x90\x00 fake-mp3-bytes"))
140 f.record(fakeHit{Text: body.Text, Start: start, End: time.Now(), Completed: true})
141 }
142
143 func (f *fakeProvider) record(h fakeHit) {
144 f.mu.Lock()
145 defer f.mu.Unlock()
146 f.hits = append(f.hits, h)
147 }
148
149 func (f *fakeProvider) setDelay(d time.Duration) {
150 f.mu.Lock()
151 f.delay = d
152 f.mu.Unlock()
153 }
154
155 func (f *fakeProvider) snapshot() []fakeHit {
156 f.mu.Lock()
157 defer f.mu.Unlock()
158 out := make([]fakeHit, len(f.hits))
159 copy(out, f.hits)
160 return out
161 }
162
163 // completed liefert nur die abgeschlossenen (nicht abgebrochenen) Treffer.
164 func completed(hits []fakeHit) []fakeHit {
165 var out []fakeHit
166 for _, h := range hits {
167 if h.Completed {
168 out = append(out, h)
169 }
170 }
171 return out
172 }
173
174 // waitArrived pollt, bis n Synthese-Requests angekommen sind — der Daemon
175 // ist damit nachweisbar beschäftigt und die Queue deterministisch befüllbar.
176 func (f *fakeProvider) waitArrived(t *testing.T, n int) {
177 t.Helper()
178 deadline := time.Now().Add(5 * time.Second)
179 for time.Now().Before(deadline) {
180 f.mu.Lock()
181 count := len(f.arrived)
182 f.mu.Unlock()
183 if count >= n {
184 return
185 }
186 time.Sleep(10 * time.Millisecond)
187 }
188 t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n)
189 }
190
191 // waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen).
192 func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
193 t.Helper()
194 deadline := time.Now().Add(5 * time.Second)
195 for time.Now().Before(deadline) {
196 if hits := f.snapshot(); len(hits) >= n {
197 return hits
198 }
199 time.Sleep(20 * time.Millisecond)
200 }
201 t.Fatalf("nur %d von %d Fake-Treffern innerhalb 5 s (Treffer: %+v)", len(f.snapshot()), n, f.snapshot())
202 return nil
203 }
204
205 // waitCompleted pollt, bis n Requests ERFOLGREICH abgeschlossen wurden
206 // (abgebrochene Synthesen zählen nicht).
207 func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit {
208 t.Helper()
209 deadline := time.Now().Add(5 * time.Second)
210 for time.Now().Before(deadline) {
211 if hits := completed(f.snapshot()); len(hits) >= n {
212 return hits
213 }
214 time.Sleep(20 * time.Millisecond)
215 }
216 t.Fatalf("nur %d von %d abgeschlossenen Treffern innerhalb 5 s (alle: %+v)", len(completed(f.snapshot())), n, f.snapshot())
217 return nil
218 }
219
220 // assertNoHits prüft, dass innerhalb der Frist kein Request ankam.
221 func (f *fakeProvider) assertNoHits(t *testing.T, wait time.Duration) {
222 t.Helper()
223 time.Sleep(wait)
224 if hits := f.snapshot(); len(hits) > 0 {
225 t.Fatalf("unerwartete Fake-Treffer: %+v", hits)
226 }
227 }
228
229 // daemonProc ist ein laufender talkd-Prozess.
230 type daemonProc struct {
231 cmd *exec.Cmd
232 port int
233 stderr *syncBuffer
234 baseURL string
235 }
236
237 // startDaemon startet talkd als echten Prozess (freier Port per TALK_PORT,
238 // nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
239 // t.Cleanup: SIGINT, nach Frist Kill.
240 func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
241 t.Helper()
242 port := freePort(t)
243 env := daemonEnv(root, port, fakeURL, extra)
244 stderr := &syncBuffer{}
245 cmd := exec.Command(talkdBin)
246 cmd.Env = env
247 cmd.Stderr = stderr
248 if err := cmd.Start(); err != nil {
249 t.Fatalf("talkd starten: %v", err)
250 }
251 d := &daemonProc{
252 cmd: cmd,
253 port: port,
254 stderr: stderr,
255 baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
256 }
257
258 client := &http.Client{Timeout: time.Second}
259 deadline := time.Now().Add(5 * time.Second)
260 for time.Now().Before(deadline) {
261 resp, err := client.Get(d.baseURL + "/status")
262 if err == nil {
263 resp.Body.Close()
264 if resp.StatusCode == http.StatusOK {
265 t.Cleanup(func() { d.stop(t) })
266 return d
267 }
268 }
269 time.Sleep(25 * time.Millisecond)
270 }
271 t.Fatalf("talkd antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
272 return nil
273 }
274
275 // stop fährt den Daemon-Prozess herunter (SIGINT wie ein menschlicher
276 // Stop; nach 3 s Kill-Fallback) und wartet auf sein Ende.
277 func (d *daemonProc) stop(t *testing.T) {
278 t.Helper()
279 if d.cmd.Process == nil {
280 return
281 }
282 done := make(chan error, 1)
283 go func() { done <- d.cmd.Wait() }()
284 _ = d.cmd.Process.Signal(os.Interrupt)
285 select {
286 case <-done:
287 case <-time.After(3 * time.Second):
288 _ = d.cmd.Process.Kill()
289 <-done
290 }
291 }
292
293 // daemonEnv baut die Prozess-Umgebung für talk- und talkd-Prozesse:
294 // geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
295 // Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
296 // damit unter <root>/talk), audio.sink=null und der Fake-Provider als
297 // ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung
298 // wird sofort abgelehnt).
299 func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string {
300 env := make([]string, 0, 32)
301 for _, kv := range os.Environ() {
302 if strings.HasPrefix(kv, "TALK_") {
303 continue
304 }
305 env = append(env, kv)
306 }
307 set := map[string]string{
308 "HOME": root,
309 "XDG_CONFIG_HOME": root,
310 "TALK_AUDIO_SINK": "null",
311 "TALK_ELEVENLABS_KEY": "prozesstest-key",
312 "TALK_ELEVENLABS_ENDPOINT": fakeURL,
313 "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
314 "TALK_PORT": strconv.Itoa(port),
315 }
316 for k, v := range extra {
317 set[k] = v
318 }
319 for k, v := range set {
320 env = append(env, k+"="+v)
321 }
322 return env
323 }
324
325 // freePort besorgt einen freien Loopback-Port (bind-and-close; der Daemon
326 // bindet ihn sofort danach neu).
327 func freePort(t *testing.T) int {
328 t.Helper()
329 ln, err := net.Listen("tcp", "127.0.0.1:0")
330 if err != nil {
331 t.Fatalf("freien Port finden: %v", err)
332 }
333 defer ln.Close()
334 return ln.Addr().(*net.TCPAddr).Port
335 }
336
337 // configDir ist das Config-Verzeichnis unter dem isolierten Root
338 // (Linux-Pfadlogik: XDG_CONFIG_HOME/<root> → <root>/talk).
339 func configDir(root string) string {
340 return filepath.Join(root, "talk")
341 }
342
343 // talkResult ist das Beobachtungsergebnis eines talk-Prozesses.
344 type talkResult struct {
345 code int
346 stdout string
347 stderr string
348 }
349
350 // runTalk führt die talk-CLI als echten Prozess aus.
351 func runTalk(t *testing.T, env []string, args ...string) talkResult {
352 t.Helper()
353 cmd := exec.Command(talkBin, args...)
354 cmd.Env = env
355 var out, errBuf syncBuffer
356 cmd.Stdout = &out
357 cmd.Stderr = &errBuf
358 err := cmd.Run()
359 code := 0
360 if ee, ok := err.(*exec.ExitError); ok {
361 code = ee.ExitCode()
362 } else if err != nil {
363 t.Fatalf("talk ausführen: %v (stderr: %s)", err, errBuf.String())
364 }
365 return talkResult{code: code, stdout: out.String(), stderr: errBuf.String()}
366 }
367
368 // statusBody ist die dekodierte GET /status-Antwort.
369 type statusBody struct {
370 Version string `json:"version"`
371 Queue int `json:"queue"`
372 QueueIDs []string `json:"queue_ids"`
373 Current *struct {
374 ID string `json:"id"`
375 Text string `json:"text"`
376 Priority string `json:"priority"`
377 } `json:"current"`
378 Muted bool `json:"muted"`
379 Provider string `json:"provider"`
380 }
381
382 // getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist
383 // lokal und muss antworten, sonst ist das selbst ein Testergebnis).
384 func getStatus(t *testing.T, d *daemonProc) statusBody {
385 t.Helper()
386 client := &http.Client{Timeout: time.Second}
387 resp, err := client.Get(d.baseURL + "/status")
388 if err != nil {
389 t.Fatalf("GET /status: %v — stderr:\n%s", err, d.stderr.String())
390 }
391 defer resp.Body.Close()
392 if resp.StatusCode != http.StatusOK {
393 t.Fatalf("GET /status: Status %d", resp.StatusCode)
394 }
395 var st statusBody
396 if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
397 t.Fatalf("GET /status dekodieren: %v", err)
398 }
399 return st
400 }
401
402 // waitStatusIdle pollt, bis weder ein Item läuft noch wartet.
403 func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) {
404 t.Helper()
405 deadline := time.Now().Add(timeout)
406 for {
407 st := getStatus(t, d)
408 if st.Current == nil && st.Queue == 0 {
409 return
410 }
411 if time.Now().After(deadline) {
412 t.Fatalf("Queue wird nicht idle: %+v — stderr:\n%s", st, d.stderr.String())
413 }
414 time.Sleep(25 * time.Millisecond)
415 }
416 }
417
418 // speak enqueue per rohem HTTP (Prozessgrenze ohne CLI).
419 func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) {
420 t.Helper()
421 resp, err := http.Post(d.baseURL+"/speak", "application/json", strings.NewReader(body))
422 if err != nil {
423 t.Fatalf("POST /speak: %v", err)
424 }
425 defer resp.Body.Close()
426 var m map[string]any
427 if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
428 t.Fatalf("POST /speak Antwort dekodieren: %v", err)
429 }
430 return resp.StatusCode, m
431 }
432
433 // postPath macht einen leeren POST auf einen Daemon-Endpunkt (/mute,
434 // /unmute, /cancel) und dekodiert die JSON-Antwort.
435 func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) {
436 t.Helper()
437 resp, err := http.Post(d.baseURL+path, "application/json", strings.NewReader("{}"))
438 if err != nil {
439 t.Fatalf("POST %s: %v", path, err)
440 }
441 defer resp.Body.Close()
442 var m map[string]any
443 if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
444 t.Fatalf("POST %s Antwort dekodieren: %v", path, err)
445 }
446 return resp.StatusCode, m
447 }
448
test/mute_test.go --- Go
1 package test
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9 )
10
11 // TestMutePersistenzUeberRestart (Abnahmekriterium): `talk mute` → Daemon-
12 // Restart → weiter stumm. Mute verwirft Ansagen dokumentiert mit
13 // 202 {id:"", muted:true} und ohne jeden Provider-Kontakt.
14 func TestMutePersistenzUeberRestart(t *testing.T) {
15 root := t.TempDir()
16 fake := newFakeProvider(t, 0)
17 d := startDaemon(t, root, fake.srv.URL, nil)
18
19 // Über die HTTP-API stummschalten: Datei mute liegt im Config-Dir.
20 code, m := postPath(t, d, "/mute")
21 if code != 200 || m["muted"] != true {
22 t.Fatalf("POST /mute: %d %v", code, m)
23 }
24 muteFile := filepath.Join(configDir(root), "mute")
25 b, err := os.ReadFile(muteFile)
26 if err != nil {
27 t.Fatalf("Mute-Datei fehlt im Config-Dir: %v", err)
28 }
29 if strings.TrimSpace(string(b)) != "true" {
30 t.Errorf("Mute-Datei = %q, want true", b)
31 }
32
33 // Ansagen werden verworfen: 202, muted:true, leere ID, kein Treffer.
34 code, m = speak(t, d, `{"text":"stille"}`)
35 if code != 202 || m["muted"] != true || m["id"] != "" {
36 t.Fatalf("speak bei Mute: %d %v, want 202 {id:, muted:true}", code, m)
37 }
38 fake.assertNoHits(t, 300*time.Millisecond)
39 if st := getStatus(t, d); !st.Muted {
40 t.Fatal("/status meldet muted=false")
41 }
42
43 // Daemon-Restart mit demselben Config-Dir: weiter stumm.
44 d.stop(t)
45 d2 := startDaemon(t, root, fake.srv.URL, nil)
46
47 if st := getStatus(t, d2); !st.Muted {
48 t.Fatalf("nach Restart muted = false, want true (persistent)")
49 }
50 code, m = speak(t, d2, `{"text":"immer noch stumm"}`)
51 if code != 202 || m["muted"] != true {
52 t.Fatalf("speak nach Restart bei Mute: %d %v", code, m)
53 }
54 fake.assertNoHits(t, 300*time.Millisecond)
55
56 // Unmute: Ansagen laufen wieder.
57 code, m = postPath(t, d2, "/unmute")
58 if code != 200 || m["muted"] != false {
59 t.Fatalf("POST /unmute: %d %v", code, m)
60 }
61 code, m = speak(t, d2, `{"text":"wieder da"}`)
62 if code != 202 || m["id"] == "" {
63 t.Fatalf("speak nach unmute: %d %v, want 202 mit id", code, m)
64 }
65 hits := fake.waitHits(t, 1)
66 if hits[0].Text != "wieder da" {
67 t.Errorf("Treffer = %q, want wieder da", hits[0].Text)
68 }
69 }
70
71 // TestDegradedMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
72 // Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
73 // dieselbe Quelle.
74 func TestDegradedMuteDateiWirdVonDaemonGelesen(t *testing.T) {
75 root := t.TempDir()
76 fake := newFakeProvider(t, 0)
77 d := startDaemon(t, root, fake.srv.URL, nil)
78 deadEnv := daemonEnv(root, 1, fake.srv.URL, nil)
79
80 // Offline stummschalten (talk mute ohne Daemon → Datei direkt).
81 res := runTalk(t, deadEnv, "mute")
82 if res.code != 0 || res.stdout != "muted\n" {
83 t.Fatalf("talk mute (offline): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
84 }
85
86 // Der laufende Daemon sieht den Zustand bei der nächsten Ansage.
87 if st := getStatus(t, d); !st.Muted {
88 t.Fatal("Daemon sieht offline geschriebene Mute-Datei nicht")
89 }
90 code, m := speak(t, d, `{"text":"soll verworfen werden"}`)
91 if code != 202 || m["muted"] != true {
92 t.Fatalf("speak: %d %v, want verworfen", code, m)
93 }
94 fake.assertNoHits(t, 300*time.Millisecond)
95
96 res = runTalk(t, deadEnv, "unmute")
97 if res.code != 0 {
98 t.Fatalf("talk unmute (offline): %d — stderr: %s", res.code, res.stderr)
99 }
100 if st := getStatus(t, d); st.Muted {
101 t.Fatal("Daemon sieht offline geschriebene Unmute-Datei nicht")
102 }
103 }
104
test/queue_test.go --- Go
1 package test
2
3 import (
4 "fmt"
5 "net/http"
6 "strings"
7 "testing"
8 "time"
9 )
10
11 // TestSerialisierung: zwei schnell hintereinander eingereihte Ansagen
12 // spielen nacheinander — die zweite Synthese erreicht den Fake-Provider
13 // erst NACH der Antwort der ersten (kein Überlappen), und die Textfolge
14 // bleibt enqueue-stabil. Der null-Sink verwirft jede Ansage sichtbar.
15 func TestSerialisierung(t *testing.T) {
16 root := t.TempDir()
17 fake := newFakeProvider(t, 250*time.Millisecond)
18 d := startDaemon(t, root, fake.srv.URL, nil)
19
20 code, first := speak(t, d, `{"text":"erste ansage"}`)
21 if code != 202 || first["id"] == "" || first["muted"] == true {
22 t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, first)
23 }
24
25 // Sobald die erste Synthese beim Fake angekommen ist (Daemon
26 // beschäftigt), zwei weitere schnell hinterher — sie müssen sich
27 // hinter der laufenden einreihen.
28 fake.waitArrived(t, 1)
29 speak(t, d, `{"text":"zweite ansage"}`)
30 speak(t, d, `{"text":"dritte ansage"}`)
31
32 hits := fake.waitHits(t, 3)
33
34 var texts []string
35 for _, h := range hits {
36 texts = append(texts, h.Text)
37 }
38 if strings.Join(texts, "|") != "erste ansage|zweite ansage|dritte ansage" {
39 t.Errorf("Abspiel-Reihenfolge = %v, want FIFO", texts)
40 }
41 // Serialisierung: die nächste Synthese startet erst nach der Antwort
42 // der vorherigen (kleine Toleranz für Uhr-Jitter).
43 const slack = 25 * time.Millisecond
44 for i := 1; i < len(hits); i++ {
45 if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
46 t.Errorf("Ansage %d überlappt mit Ansage %d: Start %v < Ende %v", i, i-1, hits[i].Start, hits[i-1].End)
47 }
48 }
49
50 // Erst idle warten: Die dritte Verwurf-Zeile entsteht daemon-seitig
51 // erst kurz NACH der beim Fake aufgezeichneten Antwort.
52 waitStatusIdle(t, d, 3*time.Second)
53
54 // Der null-Sink hat alle drei verworfen (tonlos, aber sichtbar).
55 if n := strings.Count(d.stderr.String(), "verworfen (null sink)"); n < 3 {
56 t.Errorf("null-Sink-Verwürfe im Daemon-Log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
57 }
58 }
59
60 // TestPrioritaet: während einer langen Ansage werden low/normal/high
61 // eingereiht — die Abarbeitung folgt high > normal > low (FIFO je Level).
62 func TestPrioritaet(t *testing.T) {
63 root := t.TempDir()
64 fake := newFakeProvider(t, 200*time.Millisecond)
65 d := startDaemon(t, root, fake.srv.URL, nil)
66
67 speak(t, d, `{"text":"blocker"}`)
68 fake.waitArrived(t, 1) // Blocker läuft, Worker gebunden.
69
70 // Eingereiht wird in Klassen-Durcheinander; die Queue-Sicht meldet
71 // IDs — also text→id einsammeln und die Reihenfolge über IDs prüfen.
72 enq := make(map[string]string)
73 for _, tc := range []struct{ body, text string }{
74 {`{"text":"niedrig","priority":"low"}`, "niedrig"},
75 {`{"text":"normal-eins"}`, "normal-eins"},
76 {`{"text":"normal-zwei"}`, "normal-zwei"},
77 {`{"text":"dringend","priority":"high"}`, "dringend"},
78 } {
79 code, m := speak(t, d, tc.body)
80 if code != 202 {
81 t.Fatalf("speak %s: Status %d", tc.body, code)
82 }
83 id, _ := m["id"].(string)
84 if id == "" {
85 t.Fatalf("speak %s: keine ID in %v", tc.body, m)
86 }
87 enq[tc.text] = id
88 }
89 wantOrder := []string{"dringend", "normal-eins", "normal-zwei", "niedrig"}
90
91 // Status zeigt aktuelles Item und die wartenden in Abarbeitungs-
92 // Reihenfolge (deterministisch: der Blocker hält den Worker).
93 st := getStatus(t, d)
94 if st.Current == nil || st.Current.Text != "blocker" {
95 t.Fatalf("current = %+v, will blocker", st.Current)
96 }
97 if st.Queue != 4 {
98 t.Fatalf("Queue-Länge = %d, want 4", st.Queue)
99 }
100 var wantIDs []string
101 for _, text := range wantOrder {
102 wantIDs = append(wantIDs, enq[text])
103 }
104 if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) {
105 t.Errorf("queue_ids = %v, want %v (Abarbeitungs-Reihenfolge)", st.QueueIDs, wantIDs)
106 }
107
108 hits := fake.waitHits(t, 5)
109 var texts []string
110 for _, h := range hits {
111 texts = append(texts, h.Text)
112 }
113 want := append([]string{"blocker"}, wantOrder...)
114 if strings.Join(texts, "|") != strings.Join(want, "|") {
115 t.Errorf("Abspiel-Reihenfolge = %v, want %v", texts, want)
116 }
117 waitStatusIdle(t, d, 3*time.Second)
118 }
119
120 // TestNowSpringtVor: --now über die CLI setzt now:true — die Ansage
121 // springt vor alle wartenden normal-Items (unterbricht aber nicht den
122 // laufenden Blocker).
123 func TestNowSpringtVor(t *testing.T) {
124 root := t.TempDir()
125 fake := newFakeProvider(t, 600*time.Millisecond)
126 d := startDaemon(t, root, fake.srv.URL, nil)
127 env := daemonEnv(root, d.port, fake.srv.URL, nil)
128
129 speak(t, d, `{"text":"blocker"}`)
130 fake.waitArrived(t, 1)
131
132 speak(t, d, `{"text":"spaeter"}`)
133
134 res := runTalk(t, env, "say", "--now", "sofort")
135 if res.code != 0 {
136 t.Fatalf("talk say --now: Exit %d, stderr: %s", res.code, res.stderr)
137 }
138 if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
139 t.Errorf("talk say --now stdout = %q, will eine Zeile mit der Item-ID", res.stdout)
140 }
141
142 hits := fake.waitHits(t, 3)
143 var texts []string
144 for _, h := range hits {
145 texts = append(texts, h.Text)
146 }
147 if strings.Join(texts, "|") != "blocker|sofort|spaeter" {
148 t.Errorf("Abspiel-Reihenfolge = %v, want [blocker sofort spaeter] — now muss vor normal springen", texts)
149 }
150 waitStatusIdle(t, d, 3*time.Second)
151 }
152
153 // TestCancel: bricht die laufende Ansage ab (Synthese-Request wird
154 // abgebrochen), leert die Queue, meldet die Gesamtzahl — und der Daemon
155 // bleibt danach funktionstüchtig.
156 func TestCancel(t *testing.T) {
157 root := t.TempDir()
158 fake := newFakeProvider(t, 5*time.Second)
159 d := startDaemon(t, root, fake.srv.URL, nil)
160
161 code, first := speak(t, d, `{"text":"blocker"}`)
162 if code != 202 {
163 t.Fatalf("speak: Status %d", code)
164 }
165 fake.waitArrived(t, 1) // Blocker steckt in der Synthese (5 s Fake-Delay).
166
167 code, second := speak(t, d, `{"text":"wartet"}`)
168 if code != 202 {
169 t.Fatalf("speak: Status %d", code)
170 }
171
172 // Status-Vertrag: aktuelles Item + ein wartendes.
173 st := getStatus(t, d)
174 if st.Current == nil || st.Current.ID != first["id"] {
175 t.Fatalf("current = %+v, will den Blocker (%v)", st.Current, first["id"])
176 }
177 if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] {
178 t.Fatalf("queue = %v (%d), will das wartende Item", st.QueueIDs, st.Queue)
179 }
180
181 code, m := postPath(t, d, "/cancel")
182 if code != 200 {
183 t.Fatalf("POST /cancel: Status %d", code)
184 }
185 if m["cancelled"] != float64(2) {
186 t.Fatalf("cancelled = %v, want 2 (laufende + wartende)", m["cancelled"])
187 }
188
189 // Der Daemon wird sofort idle (Synthese abgebrochen) und nimmt neue
190 // Ansagen an — cancel beschädigt den Daemon nicht.
191 waitStatusIdle(t, d, 2*time.Second)
192 fake.setDelay(50 * time.Millisecond)
193 speak(t, d, `{"text":"nachher"}`)
194 hits := fake.waitCompleted(t, 1)
195 if hits[0].Text != "nachher" {
196 t.Errorf("erster abgeschlossener Treffer = %q, will nachher (abgebrochene zählen nicht)", hits[0].Text)
197 }
198 waitStatusIdle(t, d, 3*time.Second)
199 }
200
201 // TestSpeakValidierung: der HTTP-Vertrag lehnt kaputte Anfragen mit 400
202 // ab (und 405 bei falscher Methode), ohne den Daemon zu beschädigen.
203 func TestSpeakValidierung(t *testing.T) {
204 root := t.TempDir()
205 fake := newFakeProvider(t, 0)
206 d := startDaemon(t, root, fake.srv.URL, nil)
207
208 cases := []struct {
209 name string
210 body string
211 }{
212 {"kein JSON", `kein json`},
213 {"text fehlt", `{}`},
214 {"text leer", `{"text":" "}`},
215 {"priority Müll", `{"text":"x","priority":"sofort"}`},
216 {"provider unbekannt", `{"text":"x","provider":"gibtsnicht"}`},
217 {"lang Müll", `{"text":"x","lang":"fr"}`},
218 }
219 for _, tc := range cases {
220 t.Run(tc.name, func(t *testing.T) {
221 code, m := speak(t, d, tc.body)
222 if code != 400 {
223 t.Fatalf("Status = %d, want 400 (Body: %v)", code, m)
224 }
225 if _, ok := m["error"]; !ok {
226 t.Errorf("Antwort ohne error-Feld: %v", m)
227 }
228 })
229 }
230
231 // Falsche Methode → 405 (Methoden-gebundene Routen).
232 resp, err := http.Post(d.baseURL+"/status", "application/json", nil)
233 if err != nil {
234 t.Fatal(err)
235 }
236 resp.Body.Close()
237 if resp.StatusCode != http.StatusMethodNotAllowed {
238 t.Errorf("POST /status: Status = %d, want 405", resp.StatusCode)
239 }
240
241 // Danach ist der Daemon weiterhin gesund und speakbar.
242 code, m := speak(t, d, `{"text":"gesund"}`)
243 if code != 202 || m["id"] == "" {
244 t.Fatalf("nach Validierungs-Fehlern: speak = %d %v, want 202 mit id", code, m)
245 }
246 fake.waitHits(t, 1)
247 }
248
249 // TestStatusVertrag: Felder der Status-Antwort im Leerlauf (Version,
250 // Mute aus, kein current, Provider aufgelöst).
251 func TestStatusVertrag(t *testing.T) {
252 root := t.TempDir()
253 fake := newFakeProvider(t, 0)
254 d := startDaemon(t, root, fake.srv.URL, nil)
255
256 st := getStatus(t, d)
257 if st.Version != "dev" {
258 t.Errorf("version = %q, want dev (ohne ldflags gebaut)", st.Version)
259 }
260 if st.Muted {
261 t.Error("muted = true im Frischstart, want false")
262 }
263 if st.Current != nil {
264 t.Errorf("current = %+v, want null im Leerlauf", st.Current)
265 }
266 if st.Queue != 0 {
267 t.Errorf("queue = %d, want 0", st.Queue)
268 }
269 // Aktiver Provider: elfenlabs zuerst, weil Key + Fake-Endpunkt gesetzt.
270 if st.Provider != "elevenlabs" {
271 t.Errorf("provider = %q, want elevenlabs", st.Provider)
272 }
273 }
274