From e1a3981d17fedcde5be9db313e163c2e3998e358 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 17:10:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20say=20=C3=BCber=20den=20Daemon,=20?= =?UTF-8?q?Steuer-Befehle=20als=20Daemon-Aufrufe,=20Degraded=20Mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - say/Default erkennt den Daemon via GET /status: enqueue + sofort zurück (stdout = Item-ID), --wait pollt bis zur Fertigstellung, --now springt vor - mute/unmute/status/cancel mit plain Output; mute/unmute schreiben ohne Daemon die Mute-Datei direkt (gleiche Quelle, wirkt sofort im Daemon) - Degraded Mode: Lock-Datei talk.lock im Config-Dir (Kollision = warten, verwaist nach 60 s), sequenzielles lokales Abspielen, mute respektiert - Handler-Signatur der Befehlstabelle erhält stdout (say/Steuer-Befehle geben Ergebnisse aus); --priority wird validiert --- cmd/talk/cli.go | 56 ++++++++++-------- cmd/talk/cli_test.go | 7 ++- cmd/talk/control.go | 123 +++++++++++++++++++++++++++++++++++++++ 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 +++- 9 files changed, 524 insertions(+), 81 deletions(-) create mode 100644 cmd/talk/control.go create mode 100644 cmd/talk/control_test.go create mode 100644 cmd/talk/daemon.go create mode 100644 cmd/talk/degraded.go diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index 30021a0..3e55175 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -21,12 +21,12 @@ const ( // die gebundenen say-Options (derzeit der einzige Befehl mit eigenen Flags; // erhält ein weiterer Befehl eigene Flags, wird die Signatur verallgemeinert). type command struct { - name string // Subcommand-Name (bindende CLI-Surface aus der Spec) - short string // Kurzbeschreibung für `talk --help` - args string // Positionsargumente-Hinweis für die Usage-Zeile - detail string // Beschreibung für `talk --help` - register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet - handler func(args []string, o *sayOptions, stdin io.Reader, stderr io.Writer) int // Ausführung (fehlt sie: Stub) + name string // Subcommand-Name (bindende CLI-Surface aus der Spec) + short string // Kurzbeschreibung für `talk --help` + args string // Positionsargumente-Hinweis für die Usage-Zeile + detail string // Beschreibung für `talk --help` + register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet + handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // Ausführung (fehlt sie: Stub) } // commands ist die vollständige Befehlstabelle. Die Namen sind Teil der @@ -47,24 +47,28 @@ var commands = []command{ detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer.", }, { - name: "mute", - short: "Ansagen dauerhaft stummschalten", - detail: "Aktiviert den persistenten Mute-State des Daemons (Datei im Config-Verzeichnis).", + name: "mute", + short: "Ansagen dauerhaft stummschalten", + detail: "Aktiviert den persistenten Mute-State (Datei `mute` im Config-Verzeichnis). Läuft der Daemon, wird er darüber informiert; andernfalls schreibt talk die Datei direkt — der Zustand überlebt Daemon-Restarts.", + handler: runMute, }, { - name: "unmute", - short: "Stummschaltung aufheben", - detail: "Hebt den persistenten Mute-State des Daemons wieder auf.", + name: "unmute", + short: "Stummschaltung aufheben", + detail: "Hebt den persistenten Mute-State wieder auf (Daemon-Aufruf; ohne Daemon direkt über die Datei).", + handler: runUnmute, }, { - name: "status", - short: "Daemon-Status anzeigen", - detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version.", + name: "status", + short: "Daemon-Status anzeigen", + detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).", + handler: runStatus, }, { - name: "cancel", - short: "Laufende und geplante Ansagen abbrechen", - detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons.", + name: "cancel", + short: "Laufende und geplante Ansagen abbrechen", + detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons; Ausgabe ist die Zahl der abgebrochenen Ansagen.", + handler: runCancel, }, { name: "voices", @@ -90,8 +94,9 @@ var commands = []command{ // addSayFlags registriert die Flags des say-Befehls und bindet sie an die // zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad -// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Das -// Verhalten: direkter Pfad in Task 02, Daemon-Pfad in Task 04. +// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Verhalten +// seit Task 04: läuft ein Daemon, wird enqueue (non-blocking); --wait pollt +// bis zur Fertigstellung, --now springt vor. Ohne Daemon: Degraded Mode. // // Rückgabe ist bewusst ein Pointer: flag.Var bindet an die Felder genau // dieser Struct — eine Wertkopie hätte nach dem Parse weiterhin die @@ -120,7 +125,7 @@ func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int { writeUsage(stderr) return exitUsage } - return runSay(nil, &sayOptions{}, stdin, stderr) + return runSay(nil, &sayOptions{}, stdin, stdout, stderr) } if argv[0] == "help" { @@ -193,8 +198,9 @@ func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOp return fs, help, sayOpts } -// runCommand führt einen benannten Subcommand aus. say ist implementiert -// (Task 02); alle anderen sind Stubs bis zu ihren Tasks. Flags dürfen auch +// runCommand führt einen benannten Subcommand aus. Implementiert: say +// (Task 02/04) sowie mute/unmute/status/cancel als Daemon-Aufrufe (Task +// 04); die restlichen sind Stubs bis zu ihren Tasks. Flags dürfen auch // nach dem Text stehen (parseInterspersed). func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs, help, sayOpts := newCommandFlagSet(cmd, stderr) @@ -213,7 +219,7 @@ func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.W return exitOK } if cmd.handler != nil { - return cmd.handler(positional, sayOpts, stdin, stderr) + return cmd.handler(positional, sayOpts, stdin, stdout, stderr) } return notImplemented(cmd.name, stderr) } @@ -245,7 +251,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int { writeUsage(stdout) return exitOK } - return runSay(positional, sayOpts, stdin, stderr) + return runSay(positional, sayOpts, stdin, stdout, stderr) } // versionString rendert die Versionsausgabe: `talk version ` plus diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go index 8937c82..34b4d60 100644 --- a/cmd/talk/cli_test.go +++ b/cmd/talk/cli_test.go @@ -14,10 +14,11 @@ var wantSubcommands = []string{ "say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup", } -// wantStubCommands ist wantSubcommands ohne say — say ist seit Task 02 -// implementiert, alle anderen bleiben Stubs bis zu ihren Tasks. +// wantStubCommands ist wantSubcommands ohne die implementierten Befehle — +// say (Task 02/04) sowie mute/unmute/status/cancel (Task 04, Daemon- +// Aufrufe) sind real; die restlichen bleiben Stubs bis zu ihren Tasks. var wantStubCommands = []string{ - "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup", + "wrap", "voices", "test", "doctor", "setup", } // runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über diff --git a/cmd/talk/control.go b/cmd/talk/control.go new file mode 100644 index 0000000..354c2f6 --- /dev/null +++ b/cmd/talk/control.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "io" + "path/filepath" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/queue" +) + +// Die Steuer-Befehle (Task 04) sind Daemon-Aufrufe mit plain Output: +// stdout führt eine maschinenlesbare Zeile (muted/unmuted, cancelled: N +// bzw. den Status-Block), stderr menschliche Hinweise. + +// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein +// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe +// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der +// Zustand greift also auch offline und überlebt Daemon-Restarts. +func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { + return setMute(true, stdout, stderr) +} + +// runUnmute implementiert `talk unmute` (analog zu runMute). +func runUnmute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { + return setMute(false, stdout, stderr) +} + +// setMute ist der gemeinsame Kern von mute/unmute. +func setMute(muted bool, stdout, stderr io.Writer) int { + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + client := api.NewClient(cfg.Server.Port) + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + defer cancel() + if err := client.SetMute(ctx, muted); err == nil { + printMuteState(muted, stdout) + return exitOK + } + + fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar (%v) — Mute-Datei wird direkt geschrieben\n", err) + dir, err := config.Dir(getenv) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + if err := (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Set(muted); err != nil { + fmt.Fprintf(stderr, "talk: Mute-Datei schreiben: %v\n", err) + return exitFailure + } + printMuteState(muted, stdout) + return exitOK +} + +// printMuteState schreibt den Zustand als plain Token auf stdout. +func printMuteState(muted bool, stdout io.Writer) { + if muted { + fmt.Fprintln(stdout, "muted") + } else { + fmt.Fprintln(stdout, "unmuted") + } +} + +// runStatus implementiert `talk status`: plain Block aus GET /status — +// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State, +// aktiver Provider. Ohne Daemon: klarer Fehler (status ist eine Anfrage an +// den Daemon; der Degraded Mode hat keinen Status). +func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + client := api.NewClient(cfg.Server.Port) + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + defer cancel() + st, err := client.Status(ctx) + if err != nil { + fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL) + return exitFailure + } + + fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version) + fmt.Fprintf(stdout, "queue: %d\n", st.Queue) + if st.Current != nil { + fmt.Fprintf(stdout, "current: %s %q (Priorität %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority) + } else { + fmt.Fprintln(stdout, "current: –") + } + muteState := "aus" + if st.Muted { + muteState = "an" + } + fmt.Fprintf(stdout, "mute: %s\n", muteState) + fmt.Fprintf(stdout, "provider: %s\n", st.Provider) + return exitOK +} + +// runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende +// Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen +// Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte. +func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { + cfg, err := config.Load(config.Options{Getenv: getenv}) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + client := api.NewClient(cfg.Server.Port) + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + defer cancel() + n, err := client.Cancel(ctx) + if err != nil { + fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s — nichts zu abbrechen\n", client.BaseURL) + return exitFailure + } + fmt.Fprintf(stdout, "cancelled: %d\n", n) + return exitOK +} diff --git a/cmd/talk/control_test.go b/cmd/talk/control_test.go new file mode 100644 index 0000000..cb6a717 --- /dev/null +++ b/cmd/talk/control_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "code.m3ta.dev/m3tam3re/talk/internal/queue" +) + +// Diese Tests decken die Steuer-Befehle ohne Daemon ab (in-process über +// die run-Seam; TALK_PORT=1 aus der setGetenv-Grundausstattung → Daemon- +// Erkennung schlägt sofort fehl). Das Verhalten MIT Daemon testen die +// Prozessgrenzen-Tests in /test. + +// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler +// mit Hinweis (kein Stillversagen). +func TestStatusOhneDaemon(t *testing.T) { + setGetenv(t, nil) + var out, errBuf bytes.Buffer + code := runCLI([]string{"status"}, &out, &errBuf) + if code != exitFailure { + t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure) + } + if !strings.Contains(errBuf.String(), "nicht erreichbar") { + t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String()) + } + if out.Len() != 0 { + t.Errorf("stdout = %q, will leer", out.String()) + } +} + +// TestCancelOhneDaemon: ohne Daemon gibt es nichts zu abbrechen — klarer +// Fehler. +func TestCancelOhneDaemon(t *testing.T) { + setGetenv(t, nil) + var out, errBuf bytes.Buffer + code := runCLI([]string{"cancel"}, &out, &errBuf) + if code != exitFailure { + t.Fatalf("talk cancel ohne Daemon: Exit-Code = %d, want %d", code, exitFailure) + } + if !strings.Contains(errBuf.String(), "nichts zu abbrechen") { + t.Errorf("stderr = %q, will Hinweis", errBuf.String()) + } +} + +// TestMuteOhneDaemonSchreibtDatei: `talk mute` ohne Daemon schreibt die +// Mute-Datei direkt — und genau diese Datei respektiert der Degraded Mode +// von say anschließend. +func TestMuteOhneDaemonSchreibtDatei(t *testing.T) { + root := t.TempDir() + setGetenv(t, map[string]string{"XDG_CONFIG_HOME": root}) + mutePath := filepath.Join(root, "talk", queue.MuteFileName) + + var out, errBuf bytes.Buffer + if code := runCLI([]string{"mute"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk mute ohne Daemon: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String()) + } + if got := out.String(); got != "muted\n" { + t.Errorf("talk mute stdout = %q, want %q", got, "muted\n") + } + if !strings.Contains(errBuf.String(), "direkt") { + t.Errorf("stderr = %q, will Hinweis auf direkten Datei-Schreib", errBuf.String()) + } + b, err := os.ReadFile(mutePath) + if err != nil { + t.Fatalf("Mute-Datei fehlt: %v", err) + } + if strings.TrimSpace(string(b)) != "true" { + t.Errorf("Mute-Datei = %q, want true", b) + } + + // say im Degraded Mode respektiert dieselbe Datei (tonlos verworfen, + // ohne irgendeinen Provider zu brauchen). + out.Reset() + errBuf.Reset() + if code := runCLI([]string{"say", "stille bitte"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk say (mute an): Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String()) + } + if got := out.String(); got != "muted\n" { + t.Errorf("talk say (mute an) stdout = %q, want %q", got, "muted\n") + } + + // unmute hebt es auf (Datei false). + out.Reset() + errBuf.Reset() + if code := runCLI([]string{"unmute"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk unmute: Exit-Code = %d (stderr: %q)", code, errBuf.String()) + } + if got := out.String(); got != "unmuted\n" { + t.Errorf("talk unmute stdout = %q, want %q", got, "unmuted\n") + } + b, err = os.ReadFile(mutePath) + if err != nil || strings.TrimSpace(string(b)) != "false" { + t.Errorf("Mute-Datei nach unmute = %q (%v), want false", b, err) + } +} + +// TestSayOhneConfigDir: Degraded Mode ohne Config-Dir (kein HOME) spielt +// weiter statt zu scheitern — Best-effort ohne Lock und Mute. +func TestSayOhneConfigDir(t *testing.T) { + seen := withFakeEspeak(t) + setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"}) + + var out, errBuf bytes.Buffer + code := runCLI([]string{"Hallo"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("talk ohne HOME: Exit-Code = %d, want %d; stderr = %q", code, exitOK, errBuf.String()) + } + if _, err := os.Stat(seen); err != nil { + t.Errorf("fake espeak nicht gerufen: %v", err) + } + if !strings.Contains(errBuf.String(), "Degraded Mode") { + t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String()) + } +} diff --git a/cmd/talk/daemon.go b/cmd/talk/daemon.go new file mode 100644 index 0000000..65af5f8 --- /dev/null +++ b/cmd/talk/daemon.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/config" +) + +// Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status) +// hält kurz: Auf einem freien Port wird die Verbindung sofort abgelehnt; +// das Budget deckt nur unglückliche Fälle (gefilterte Ports), damit talk +// ohne Daemon nicht spürbar trödelt. +const ( + daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status) + daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe + waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz +) + +// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status, +// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking), +// stdout erhält die Item-ID; --wait pollt /status, bis die Ansage weder +// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage — +// stdout sagt „muted". +// +// handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den +// Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue +// (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus +// (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon +// angekommen und wurde bewusst abgelehnt. +func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) (code int, handled bool) { + client := api.NewClient(cfg.Server.Port) + + probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout) + defer cancelProbe() + if _, err := client.Status(probeCtx); err != nil { + return 0, false + } + + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + defer cancel() + resp, err := client.Speak(ctx, api.SpeakRequest{ + Text: text, + Priority: opts.priority, + Provider: opts.provider, + Voice: opts.voice, + Lang: opts.lang, + Now: opts.now, + }) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + fmt.Fprintf(stderr, "talk: Daemon hat die Ansage abgelehnt: %v\n", err) + return exitFailure, true + } + return 0, false + } + if resp.Muted { + fmt.Fprintln(stdout, "muted") + fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") + return exitOK, true + } + fmt.Fprintln(stdout, resp.ID) + if !opts.wait { + return exitOK, true + } + return waitForItem(client, resp.ID, stderr), true +} + +// waitForItem pollt /status, bis die Ansage id weder läuft noch wartet — +// sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer +// nichts mehr zu warten). Stirbt der Daemon während des Wartens, ist das +// ein Fehler: Der Zustand der Ansage ist unbekannt, und still warten wäre +// das Gegenteil von --wait. +func waitForItem(client *api.Client, id string, stderr io.Writer) int { + for { + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + st, err := client.Status(ctx) + cancel() + if err != nil { + fmt.Fprintf(stderr, "talk: Daemon beim Warten nicht mehr erreichbar (%v)\n", err) + return exitFailure + } + if !st.PendingOrCurrent(id) { + return exitOK + } + time.Sleep(waitPollInterval) + } +} diff --git a/cmd/talk/degraded.go b/cmd/talk/degraded.go new file mode 100644 index 0000000..74c7f1b --- /dev/null +++ b/cmd/talk/degraded.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/audio" + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/langdetect" + "code.m3ta.dev/m3tam3re/talk/internal/provider" + "code.m3ta.dev/m3tam3re/talk/internal/queue" +) + +// Degraded Mode (Spec: „ohne talkd: Lock-File + sequenzielles Abspielen; +// nichts bricht still"). Die Lock-Datei talk.lock im Config-Dir +// serialisiert konkurrierende talk-Prozesse; Kollision heißt warten. +const ( + lockFileName = "talk.lock" + lockStaleAfter = time.Minute // abgestürzter Prozess: Lock gilt als verwais + lockWaitTimeout = 10 * time.Second // Kollision: so lange auf das Lock warten + lockPollInterval = 50 * time.Millisecond +) + +// sayDegraded ist der lokale Pfad ohne Daemon: mute respektieren (dieselbe +// Mute-Datei, die der Daemon liest), über die Lock-Datei serialisieren, +// dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im +// Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das +// Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und +// Mute weiter, statt zu scheitern. +func sayDegraded(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) int { + if dir, err := config.Dir(getenv); err == nil { + mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)} + if mute.Muted() { + fmt.Fprintln(stdout, "muted") + fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") + return exitOK + } + release, err := acquireLock(filepath.Join(dir, lockFileName)) + if err != nil { + fmt.Fprintf(stderr, "talk: Lock-Datei nicht erhalten (%v) — spiele ohne Lock (best-effort)\n", err) + } else { + defer release() + } + } + + lang := opts.lang + if lang == "" { + lang = langdetect.Detect(text) + } + + sink, err := audio.NewSink(cfg.Audio.Sink, stderr) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + + chain, err := provider.NewChain(provider.ChainConfig{ + Pin: pickProvider(opts.provider, cfg.Provider), + Voice: opts.voice, + Voices: cfg.Voice, + Endpoints: endpointsOf(cfg), + Model: cfg.Providers["openai"].Model, + Getenv: getenv, + Player: sink, + Log: stderr, + }) + if err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + + if err := chain.Speak(context.Background(), text, lang); err != nil { + fmt.Fprintf(stderr, "talk: %v\n", err) + return exitFailure + } + return exitOK +} + +// acquireLock legt die Lock-Datei exklusiv an (O_CREATE|O_EXCL — reine +// stdlib, auf allen Plattformen verfügbar). Ist sie belegt, wird gepollt +// (Kollision = warten); wirkt sie verwais (Prozess-Crash), wird sie nach +// lockStaleAfter einmalig entfernt. Rückgabe ist die release-Funktion +// (Datei schließen und freigeben); ihr Fehler ist bewusst geschluckt — +// Best-effort heißt: ein hängengebliebenes Lock blockiert künftige Läufe +// nur bis zur Stale-Frist. +func acquireLock(path string) (release func(), err error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + deadline := time.Now().Add(lockWaitTimeout) + for { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + return func() { + f.Close() + _ = os.Remove(path) + }, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, err + } + if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter { + _ = os.Remove(path) + continue + } + if !time.Now().Before(deadline) { + return nil, fmt.Errorf("Lock-Datei %s bleibt belegt", path) + } + time.Sleep(lockPollInterval) + } +} diff --git a/cmd/talk/main.go b/cmd/talk/main.go index b79e5de..29fd28e 100644 --- a/cmd/talk/main.go +++ b/cmd/talk/main.go @@ -1,9 +1,11 @@ // talk ist die CLI für Sprach-Ausgaben (talk 2.0). // -// Dieser Stand (Task 02) liefert den say-Pfad end-to-end: Text aus -// Argument oder stdin → Spracherkennung → Provider (osbuiltin) → -// Wiedergabe über einen Audio-Sink (oto/null). Die übrigen Befehle sind -// Stubs; Daemon, Kette und TUI folgen in den Tasks 03–09. +// Dieser Stand (Task 04): say (und der Default-Aufruf `talk "Text"`) reicht +// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur +// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded +// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/ +// status/cancel sind Daemon-Aufrufe mit plain Output. Die übrigen Befehle +// sind Stubs; TUI und weitere Aufgaben folgen in den Tasks 05–09. package main import "os" diff --git a/cmd/talk/say.go b/cmd/talk/say.go index a9bd3d7..2001505 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -1,20 +1,18 @@ package main import ( - "context" "fmt" "io" "os" "strings" - "code.m3ta.dev/m3tam3re/talk/internal/audio" "code.m3ta.dev/m3tam3re/talk/internal/config" - "code.m3ta.dev/m3tam3re/talk/internal/langdetect" - "code.m3ta.dev/m3tam3re/talk/internal/provider" + "code.m3ta.dev/m3tam3re/talk/internal/queue" ) -// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK -// etc., ohne den Prozess-Environment anzufassen). Default: os.Getenv. +// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK, +// TALK_PORT etc., ohne den Prozess-Environment anzufassen). Default: +// os.Getenv. var getenv = os.Getenv // sayOptions bündelt die Flags von say (identisch im Default-Pfad @@ -28,15 +26,15 @@ type sayOptions struct { provider string } -// runSay ist der komplette Ansage-Pfad (direkt und blockierend; -// Daemon-Anbindung folgt in Task 04): Text aus Argumenten oder stdin → -// Spracherkennung → Provider-Kette (Task 03: elevenlabs → openai → edge → -// osbuiltin; --provider/config provider pinnen) → Synthese mit Fallback → -// Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern kann (Linux -// ohne espeak), springt die dokumentierte Erweiterung provider.DirectSpeaker -// an (spd-say); mit audio.sink=null wird die Ansage stattdessen verworfen — -// CI/Server bleiben ton- und fehlerfrei. -func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int { +// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln +// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd +// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking +// als Default, --wait pollt bis zur Fertigstellung, --now springt vor, +// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein +// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal, +// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden +// Pfarden dieselbe (internal/provider, unverändert). +func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int { text := strings.Join(args, " ") if text == "" { if isCharDevice(stdin) { @@ -55,12 +53,14 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) } } - lang := opts.lang - if lang == "" { - lang = langdetect.Detect(text) + // Validierung vor jedem Daemon-Kontakt: Benutzungsfehler sollen + // schnell und ohne Netzwerk-Umwege auffallen. + if opts.lang != "" && opts.lang != "de" && opts.lang != "en" { + fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang) + return exitFailure } - if lang != "de" && lang != "en" { - fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", lang) + if opts.priority != "" && !queue.Priority(opts.priority).Valid() { + fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority) return exitFailure } @@ -70,35 +70,12 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) return exitFailure } - // Sink zuerst: Die Kette braucht ihn als Player (Bytes-Wiedergabe) und - // für die null-Entscheidung bei der Direktansage. - sink, err := audio.NewSink(cfg.Audio.Sink, stderr) - if err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure + if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled { + return code } - chain, err := provider.NewChain(provider.ChainConfig{ - Pin: pickProvider(opts.provider, cfg.Provider), - Voice: opts.voice, - Voices: cfg.Voice, - Endpoints: endpointsOf(cfg), - Model: cfg.Providers["openai"].Model, - Getenv: getenv, - Player: sink, - Log: stderr, - }) - if err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure - } - - ctx := context.Background() - if err := chain.Speak(ctx, text, lang); err != nil { - fmt.Fprintf(stderr, "talk: %v\n", err) - return exitFailure - } - return exitOK + fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)") + return sayDegraded(cfg, text, opts, stdout, stderr) } // pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt diff --git a/cmd/talk/say_test.go b/cmd/talk/say_test.go index c515eab..9854f7b 100644 --- a/cmd/talk/say_test.go +++ b/cmd/talk/say_test.go @@ -14,10 +14,17 @@ import ( // stattung jedes Tests: TALK_EDGE_ENDPOINT zeigt auf einen garantiert // toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default- // Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt -// anfassen. Wer edge bewusst anders braucht, überschreibt den Key. +// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung +// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch +// den Degraded Mode laufen (und nie einen zufällig laufenden echten talkd +// treffen). Wer einen dieser Ports bewusst anders braucht, überschreibt +// den Key. func setGetenv(t *testing.T, kv map[string]string) { t.Helper() - base := map[string]string{"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/"} + base := map[string]string{ + "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", + "TALK_PORT": "1", + } for k, v := range kv { base[k] = v }