# 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 --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 ` 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:). 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.]`). 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/): 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 /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/ → /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