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
|
args string // Positionsargumente-Hinweis für die Usage-Zeile
|
||||||
detail string // Beschreibung für `talk <Befehl> --help`
|
detail string // Beschreibung für `talk <Befehl> --help`
|
||||||
register func(fs *flag.FlagSet) *sayOptions // hängt die Flags des Befehls an den FlagSet
|
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
|
// commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
|
||||||
@@ -49,22 +49,26 @@ var commands = []command{
|
|||||||
{
|
{
|
||||||
name: "mute",
|
name: "mute",
|
||||||
short: "Ansagen dauerhaft stummschalten",
|
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",
|
name: "unmute",
|
||||||
short: "Stummschaltung aufheben",
|
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",
|
name: "status",
|
||||||
short: "Daemon-Status anzeigen",
|
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",
|
name: "cancel",
|
||||||
short: "Laufende und geplante Ansagen abbrechen",
|
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",
|
name: "voices",
|
||||||
@@ -90,8 +94,9 @@ var commands = []command{
|
|||||||
|
|
||||||
// addSayFlags registriert die Flags des say-Befehls und bindet sie an die
|
// addSayFlags registriert die Flags des say-Befehls und bindet sie an die
|
||||||
// zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad
|
// zurückgegebene Options-Struct. Sie gelten auch für den Default-Pfad
|
||||||
// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Das
|
// `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der Spec). Verhalten
|
||||||
// Verhalten: direkter Pfad in Task 02, Daemon-Pfad in Task 04.
|
// 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
|
// Rückgabe ist bewusst ein Pointer: flag.Var bindet an die Felder genau
|
||||||
// dieser Struct — eine Wertkopie hätte nach dem Parse weiterhin die
|
// 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)
|
writeUsage(stderr)
|
||||||
return exitUsage
|
return exitUsage
|
||||||
}
|
}
|
||||||
return runSay(nil, &sayOptions{}, stdin, stderr)
|
return runSay(nil, &sayOptions{}, stdin, stdout, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if argv[0] == "help" {
|
if argv[0] == "help" {
|
||||||
@@ -193,8 +198,9 @@ func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOp
|
|||||||
return fs, help, sayOpts
|
return fs, help, sayOpts
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCommand führt einen benannten Subcommand aus. say ist implementiert
|
// runCommand führt einen benannten Subcommand aus. Implementiert: say
|
||||||
// (Task 02); alle anderen sind Stubs bis zu ihren Tasks. Flags dürfen auch
|
// (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).
|
// nach dem Text stehen (parseInterspersed).
|
||||||
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
|
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
|
return exitOK
|
||||||
}
|
}
|
||||||
if cmd.handler != nil {
|
if cmd.handler != nil {
|
||||||
return cmd.handler(positional, sayOpts, stdin, stderr)
|
return cmd.handler(positional, sayOpts, stdin, stdout, stderr)
|
||||||
}
|
}
|
||||||
return notImplemented(cmd.name, stderr)
|
return notImplemented(cmd.name, stderr)
|
||||||
}
|
}
|
||||||
@@ -245,7 +251,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
writeUsage(stdout)
|
writeUsage(stdout)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
return runSay(positional, sayOpts, stdin, stderr)
|
return runSay(positional, sayOpts, stdin, stdout, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
// 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",
|
"say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantStubCommands ist wantSubcommands ohne say — say ist seit Task 02
|
// wantStubCommands ist wantSubcommands ohne die implementierten Befehle —
|
||||||
// implementiert, alle anderen bleiben Stubs bis zu ihren Tasks.
|
// 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{
|
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
|
// 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).
|
// talk ist die CLI für Sprach-Ausgaben (talk 2.0).
|
||||||
//
|
//
|
||||||
// Dieser Stand (Task 02) liefert den say-Pfad end-to-end: Text aus
|
// Dieser Stand (Task 04): say (und der Default-Aufruf `talk "Text"`) reicht
|
||||||
// Argument oder stdin → Spracherkennung → Provider (osbuiltin) →
|
// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur
|
||||||
// Wiedergabe über einen Audio-Sink (oto/null). Die übrigen Befehle sind
|
// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded
|
||||||
// Stubs; Daemon, Kette und TUI folgen in den Tasks 03–09.
|
// 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
|
package main
|
||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
|
|||||||
+24
-47
@@ -1,20 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK
|
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
|
||||||
// etc., ohne den Prozess-Environment anzufassen). Default: os.Getenv.
|
// TALK_PORT etc., ohne den Prozess-Environment anzufassen). Default:
|
||||||
|
// os.Getenv.
|
||||||
var getenv = os.Getenv
|
var getenv = os.Getenv
|
||||||
|
|
||||||
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
|
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
|
||||||
@@ -28,15 +26,15 @@ type sayOptions struct {
|
|||||||
provider string
|
provider string
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSay ist der komplette Ansage-Pfad (direkt und blockierend;
|
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
||||||
// Daemon-Anbindung folgt in Task 04): Text aus Argumenten oder stdin →
|
// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd
|
||||||
// Spracherkennung → Provider-Kette (Task 03: elevenlabs → openai → edge →
|
// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking
|
||||||
// osbuiltin; --provider/config provider pinnen) → Synthese mit Fallback →
|
// als Default, --wait pollt bis zur Fertigstellung, --now springt vor,
|
||||||
// Sink.Play. Auf Plattformen, deren OS-TTS keine Bytes liefern kann (Linux
|
// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein
|
||||||
// ohne espeak), springt die dokumentierte Erweiterung provider.DirectSpeaker
|
// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal,
|
||||||
// an (spd-say); mit audio.sink=null wird die Ansage stattdessen verworfen —
|
// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden
|
||||||
// CI/Server bleiben ton- und fehlerfrei.
|
// Pfarden dieselbe (internal/provider, unverändert).
|
||||||
func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer) int {
|
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
text := strings.Join(args, " ")
|
text := strings.Join(args, " ")
|
||||||
if text == "" {
|
if text == "" {
|
||||||
if isCharDevice(stdin) {
|
if isCharDevice(stdin) {
|
||||||
@@ -55,12 +53,14 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lang := opts.lang
|
// Validierung vor jedem Daemon-Kontakt: Benutzungsfehler sollen
|
||||||
if lang == "" {
|
// schnell und ohne Netzwerk-Umwege auffallen.
|
||||||
lang = langdetect.Detect(text)
|
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" {
|
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
|
||||||
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", lang)
|
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
|
||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,35 +70,12 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stderr io.Writer)
|
|||||||
return exitFailure
|
return exitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sink zuerst: Die Kette braucht ihn als Player (Bytes-Wiedergabe) und
|
if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled {
|
||||||
// für die null-Entscheidung bei der Direktansage.
|
return code
|
||||||
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{
|
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
|
||||||
Pin: pickProvider(opts.provider, cfg.Provider),
|
return sayDegraded(cfg, text, opts, stdout, stderr)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
|
// 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
|
// stattung jedes Tests: TALK_EDGE_ENDPOINT zeigt auf einen garantiert
|
||||||
// toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default-
|
// toten Loopback-Port (Verbindung wird sofort abgelehnt) — die Default-
|
||||||
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
|
// 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) {
|
func setGetenv(t *testing.T, kv map[string]string) {
|
||||||
t.Helper()
|
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 {
|
for k, v := range kv {
|
||||||
base[k] = v
|
base[k] = v
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user