feat(cli): say über den Daemon, Steuer-Befehle als Daemon-Aufrufe, Degraded Mode
- 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
This commit is contained in:
1 parent
d09aff6874
commit
e1a3981d17
9 files changed
+511
-68
No files matched your search
+18
-12
@@ -26,7 +26,7 @@ type command struct {
|
||||
args string // Positionsargumente-Hinweis für die Usage-Zeile
|
||||
detail string // Beschreibung für `talk <Befehl> --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)
|
||||
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
|
||||
@@ -49,22 +49,26 @@ var commands = []command{
|
||||
{
|
||||
name: "mute",
|
||||
short: "Ansagen dauerhaft stummschalten",
|
||||
detail: "Aktiviert den persistenten Mute-State des Daemons (Datei im Config-Verzeichnis).",
|
||||
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.",
|
||||
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.",
|
||||
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.",
|
||||
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 <version>` plus
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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"
|
||||
|
||||
+24
-47
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user