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:
m3ta-chiron committed 2026-10-18 17:10:36 +02:00
1 parent d09aff6874
commit e1a3981d17
9 files changed
+524 -81

No files matched your search

+31 -25
View File
@@ -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 <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)
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 <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, 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 <version>` plus
+4 -3
View File
@@ -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
+123
View File
@@ -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
}
+118
View File
@@ -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())
}
}
+93
View File
@@ -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)
}
}
+116
View File
@@ -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
View File
@@ -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
View File
@@ -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
+9 -2
View File
@@ -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
}