feat(notify): desktop notification per announcement with a Cancel button

Every playing announcement now shows a desktop notification with its text
and, on Linux, a Cancel button (Abbrechen for German) that stops exactly
that announcement; the notification is closed when the announcement ends.
This brings back what the old talk.sh had with notify-send --action, as a
default: a long announcement can be cut short without a terminal.

Implementation: the freedesktop Notifications service on the session bus
via godbus (pure Go): one connection per process, Notify with the action
list, ActionInvoked dispatched to the announcement, CloseNotification at
the end, expire_timeout 0 so the button stays for the whole announcement.
Without a session bus the notify-send fallback shows a plain notification;
macOS and Windows show one without a button for now. queue.CancelItem(id)
cancels only the playing item. Config [notify] on_speak (default true, env
TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a
click; `talk doctor` names the service and whether it supports the button.

Config hygiene found on the way: a key written below a [table] header
belongs to that table in TOML (lang under [voice] was silently ignored).
config.Load now collects unknown keys (undecoded ones and non-language
keys under [voice.<provider>]) and `talk doctor` warns with the hint.

Tests: process test on a private dbus-daemon with a fake
org.freedesktop.Notifications service (Notify payload, cancel click aborts
the synthesis, CloseNotification), queue.CancelItem, config keys. All test
binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever
pops a notification on the developer's desktop. flake vendorHash updated
for the new dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 12:10:49 +02:00
1 parent 6f3e7c9f35
commit 83a15af9c5
22 files changed
+806 -45

No files matched your search

+34 -9
View File
@@ -84,7 +84,34 @@ func Run(ctx context.Context, opts Options) error {
if err != nil {
return err
}
q := queue.New(queue.Laws{
// 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{
RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
@@ -92,12 +119,10 @@ func Run(ctx context.Context, opts Options) error {
MaxText: cfg.Queue.MaxText,
MaxAge: cfg.Queue.MaxAge,
}, func(ctx context.Context, item queue.Item) error {
out, err := engine.Speak(ctx, speak.Request{
Text: item.Text,
Lang: item.Lang,
Provider: item.Provider,
Voice: item.Voice,
})
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)
if err == nil && !out.Discarded {
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
@@ -175,8 +200,8 @@ func Run(ctx context.Context, opts Options) error {
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%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(),
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,
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
select {