2026-10-24 11:15:35 +02:00
|
|
|
// Package daemon is talk's background process: HTTP API on 127.0.0.1, a
|
|
|
|
|
// serialized announcement queue with priorities and the now jump,
|
|
|
|
|
// persistent mute state, the anti-spam laws (rate limit, quiet hours), the
|
|
|
|
|
// CORS door and the MCP endpoint. There is exactly one binary: `talk daemon
|
|
|
|
|
// run` runs the daemon in the foreground, and the CLI starts it in the
|
|
|
|
|
// background itself via Spawn when needed (daemon.autostart).
|
2026-10-24 09:49:16 +02:00
|
|
|
package daemon
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"log"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
2026-10-24 10:37:46 +02:00
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/notify"
|
2026-10-24 09:49:16 +02:00
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Files of the daemon in the config directory.
|
2026-10-24 09:49:16 +02:00
|
|
|
const (
|
2026-10-24 11:15:35 +02:00
|
|
|
// LogFileName is the log of a daemon started in the background (Spawn
|
|
|
|
|
// redirects stdout/stderr there; fresh per start).
|
2026-10-24 09:49:16 +02:00
|
|
|
LogFileName = "daemon.log"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// PIDFileName holds the process ID of the running daemon (for
|
|
|
|
|
// diagnostics and as an emergency brake when the HTTP API does not
|
|
|
|
|
// answer).
|
2026-10-24 09:49:16 +02:00
|
|
|
PIDFileName = "daemon.pid"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Options are the dependencies of Run.
|
2026-10-24 09:49:16 +02:00
|
|
|
type Options struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Config is the loaded configuration (port, laws, sink, ...).
|
2026-10-24 09:49:16 +02:00
|
|
|
Config config.Config
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Getenv supplies keys and endpoint overrides (nil = os.Getenv).
|
2026-10-24 09:49:16 +02:00
|
|
|
Getenv func(string) string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Version is the daemon version for /status and MCP serverInfo.
|
2026-10-24 09:49:16 +02:00
|
|
|
Version string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Log receives the log output (foreground: stderr; background: the log
|
|
|
|
|
// file). nil = os.Stderr.
|
2026-10-24 09:49:16 +02:00
|
|
|
Log io.Writer
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Run runs the daemon until ctx ends (signal), POST /shutdown arrives or
|
|
|
|
|
// the HTTP server fails. A playing announcement is cancelled on shutdown;
|
|
|
|
|
// the daemon should react promptly. A port in use is a clear error ("is
|
|
|
|
|
// another daemon running?").
|
2026-10-24 09:49:16 +02:00
|
|
|
func Run(ctx context.Context, opts Options) error {
|
|
|
|
|
getenv := opts.Getenv
|
|
|
|
|
if getenv == nil {
|
|
|
|
|
getenv = os.Getenv
|
|
|
|
|
}
|
|
|
|
|
out := opts.Log
|
|
|
|
|
if out == nil {
|
|
|
|
|
out = os.Stderr
|
|
|
|
|
}
|
|
|
|
|
logger := log.New(out, "talk daemon: ", log.LstdFlags)
|
|
|
|
|
lw := lineWriter{logger}
|
|
|
|
|
cfg := opts.Config
|
|
|
|
|
|
|
|
|
|
engine, err := speak.New(cfg, getenv, lw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Anti-spam law: config.Load has validated quiet_hours; an error here
|
|
|
|
|
// is practically impossible but must never be silent.
|
2026-10-24 09:49:16 +02:00
|
|
|
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-10-24 12:10:49 +02:00
|
|
|
// Desktop notification per announcement (notify.on_speak): shown when the
|
|
|
|
|
// announcement starts, with a Cancel button where the platform has one,
|
|
|
|
|
// closed when it ends. Best effort: the first failure is logged, later
|
|
|
|
|
// ones are silent, speech never waits for a notification problem.
|
|
|
|
|
var q *queue.Queue
|
|
|
|
|
var notifyFailed sync.Once
|
|
|
|
|
announceNotification := func(ctx context.Context, item queue.Item, lang string) func() {
|
|
|
|
|
if !cfg.Notify.OnSpeak {
|
|
|
|
|
return func() {}
|
|
|
|
|
}
|
|
|
|
|
label := "Cancel"
|
|
|
|
|
if lang == "de" {
|
|
|
|
|
label = "Abbrechen"
|
|
|
|
|
}
|
|
|
|
|
closeFn, err := notify.Announce(ctx, item.Text, label, func() {
|
|
|
|
|
if q.CancelItem(item.ID) {
|
|
|
|
|
logger.Printf("announcement %s cancelled from its notification", item.ID)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
notifyFailed.Do(func() {
|
|
|
|
|
logger.Printf("warning: notifications unavailable (%v); announcements play without one. Set [notify] on_speak = false to silence this", err)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return closeFn
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
q = queue.New(queue.Laws{
|
2026-10-24 09:49:16 +02:00
|
|
|
RateLimit: cfg.Queue.RateLimit,
|
|
|
|
|
AllowHighBurst: cfg.Queue.AllowHighBurst,
|
|
|
|
|
Quiet: quiet,
|
|
|
|
|
QuietMode: cfg.QuietMode,
|
2026-10-24 09:54:40 +02:00
|
|
|
MaxText: cfg.Queue.MaxText,
|
|
|
|
|
MaxAge: cfg.Queue.MaxAge,
|
2026-10-24 09:49:16 +02:00
|
|
|
}, func(ctx context.Context, item queue.Item) error {
|
2026-10-24 12:10:49 +02:00
|
|
|
req := speak.Request{Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice}
|
|
|
|
|
closeNotification := announceNotification(ctx, item, engine.Lang(req))
|
|
|
|
|
defer closeNotification()
|
|
|
|
|
out, err := engine.Speak(ctx, req)
|
2026-10-24 10:08:44 +02:00
|
|
|
if err == nil && !out.Discarded {
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
|
2026-10-24 10:08:44 +02:00
|
|
|
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
|
|
|
|
|
}
|
|
|
|
|
return err
|
2026-10-24 09:49:16 +02:00
|
|
|
}, lw)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Quiet-hours toast: desktop notification instead of audio (quiet_mode
|
|
|
|
|
// = "toast"); best effort, errors only in the log. Runs asynchronously
|
|
|
|
|
// so a hanging notification daemon never blocks the queue.
|
2026-10-24 10:37:46 +02:00
|
|
|
if cfg.QuietMode == queue.QuietModeToast {
|
|
|
|
|
if notify.Available() {
|
|
|
|
|
q.OnToast(func(item queue.Item) {
|
|
|
|
|
go func() {
|
|
|
|
|
if err := notify.Show(context.Background(), item.Text); err != nil {
|
|
|
|
|
logger.Printf("toast: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
})
|
|
|
|
|
} else {
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("warning: quiet_mode = toast, but %s; quiet-hours announcements are only logged", notify.Describe())
|
2026-10-24 10:37:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Persistent mute state and PID file in the config dir. Without a
|
|
|
|
|
// config dir the daemon keeps running without both (warning) instead
|
|
|
|
|
// of failing.
|
2026-10-24 09:49:16 +02:00
|
|
|
var mutePath, pidPath string
|
|
|
|
|
if dir, err := config.Dir(getenv); err == nil {
|
|
|
|
|
mutePath = filepath.Join(dir, queue.MuteFileName)
|
|
|
|
|
pidPath = filepath.Join(dir, PIDFileName)
|
|
|
|
|
} else {
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("warning: config directory unknown; mute state and PID file disabled (%v)", err)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
mute := queue.MuteStore{Path: mutePath}
|
|
|
|
|
|
|
|
|
|
shutdown := make(chan struct{})
|
|
|
|
|
var once sync.Once
|
|
|
|
|
handler := api.NewHandler(api.HandlerConfig{
|
|
|
|
|
Queue: q,
|
|
|
|
|
Mute: mute,
|
|
|
|
|
Version: opts.Version,
|
|
|
|
|
KnownProviders: provider.Names(),
|
|
|
|
|
AllowedOrigins: cfg.Server.AllowedOrigins,
|
|
|
|
|
Provider: func() string {
|
|
|
|
|
if cur, ok := q.Current(); ok {
|
|
|
|
|
return engine.ActiveProvider(cur.Provider)
|
|
|
|
|
}
|
|
|
|
|
if last := engine.LastProvider(); last != "" {
|
|
|
|
|
return last
|
|
|
|
|
}
|
|
|
|
|
return engine.ActiveProvider("")
|
|
|
|
|
},
|
|
|
|
|
Shutdown: func() { once.Do(func() { close(shutdown) }) },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port)
|
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
q.Close()
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("bind %s: %w; is another talk daemon running?", addr, err)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if pidPath != "" {
|
|
|
|
|
writePID(pidPath, logger)
|
2026-10-24 11:15:35 +02:00
|
|
|
// The PID file disappears last: `talk daemon stop` waits for that
|
|
|
|
|
// and then knows the process is done, not just HTTP.
|
2026-10-24 09:49:16 +02:00
|
|
|
defer os.Remove(pidPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
|
go func() { errCh <- srv.Serve(ln) }()
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
corsLog := "closed (no CORS)"
|
2026-10-24 09:49:16 +02:00
|
|
|
if len(cfg.Server.AllowedOrigins) > 0 {
|
|
|
|
|
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
|
|
|
|
|
}
|
2026-10-24 12:10:49 +02:00
|
|
|
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, notify=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
|
|
|
|
|
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(), cfg.Notify.OnSpeak,
|
2026-10-24 09:54:40 +02:00
|
|
|
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
|
2026-10-24 09:49:16 +02:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case err := <-errCh:
|
|
|
|
|
q.Close()
|
|
|
|
|
return fmt.Errorf("http server: %w", err)
|
|
|
|
|
case <-ctx.Done():
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("signal received; shutting down (the playing announcement is cancelled)")
|
2026-10-24 09:49:16 +02:00
|
|
|
case <-shutdown:
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("shutdown requested (talk daemon stop); shutting down")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Order: close HTTP first (no new requests), then close the queue
|
|
|
|
|
// (cancel the playing announcement, stop the worker and wait for it).
|
|
|
|
|
// That way no announcement ever keeps playing after the daemon ends.
|
2026-10-24 09:49:16 +02:00
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
|
|
|
logger.Printf("http shutdown: %v", err)
|
|
|
|
|
}
|
|
|
|
|
q.Close()
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// writePID writes our own process ID (best effort; an error is only a log
|
|
|
|
|
// line, the daemon works without a PID file too).
|
2026-10-24 09:49:16 +02:00
|
|
|
func writePID(path string, logger *log.Logger) {
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("warning: PID file: %v", err)
|
2026-10-24 09:49:16 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
logger.Printf("warning: PID file: %v", err)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// ReadPID reads the PID file; 0 if no (readable) file exists.
|
2026-10-24 09:49:16 +02:00
|
|
|
func ReadPID(dir string) int {
|
|
|
|
|
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
|
|
|
|
if err != nil || pid <= 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return pid
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// lineWriter forwards lines to the logger (timestamp + prefix) instead of
|
|
|
|
|
// writing them raw: provider chain, queue and null sink write line by line,
|
|
|
|
|
// which yields one uniform log.
|
2026-10-24 09:49:16 +02:00
|
|
|
type lineWriter struct{ l *log.Logger }
|
|
|
|
|
|
|
|
|
|
func (w lineWriter) Write(p []byte) (int, error) {
|
|
|
|
|
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
|
|
|
|
|
if line != "" {
|
|
|
|
|
w.l.Print(line)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return len(p), nil
|
|
|
|
|
}
|