feat(cli): talk test --toast sends a test desktop notification

Notifications could only be tried through an active quiet window with
quiet_mode = toast and a daemon restart. `talk test --toast` sends a test
notification directly through the same notify path, reports the tool
(notify-send / Notification Center / Windows toast) and the result, and
exits 1 with a hint when no tool exists or the call fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:55:24 +02:00
1 parent 1b971bc3f6
commit 6f3e7c9f35
5 files changed
+83 -5

No files matched your search

+6 -3
View File
@@ -22,7 +22,7 @@ dependencies.
| `talk cancel` | cancel the playing and all queued announcements | | `talk cancel` | cancel the playing and all queued announcements |
| `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) | | `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) |
| `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) | | `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) |
| `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`) | | `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`); `--toast` sends a test notification instead |
| `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) | | `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) |
| `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml | | `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml |
@@ -78,7 +78,9 @@ Keys never go into config.toml.
- **`talk test`** speaks a fixed test sentence directly in this process (not - **`talk test`** speaks a fixed test sentence directly in this process (not
via the daemon; mute and quiet hours do not apply here, an active mute is via the daemon; mute and quiet hours do not apply here, an active mute is
mentioned) and shows which tier spoke and how long synthesis and playback mentioned) and shows which tier spoke and how long synthesis and playback
took. took. `talk test --toast` sends a test desktop notification through the
same path `quiet_mode = toast` uses, so you can check notifications
without a quiet window or a daemon restart.
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade, - **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
talk uses without `--voice` (config `voice.<provider>.<lang>` or the talk uses without `--voice` (config `voice.<provider>.<lang>` or the
@@ -184,7 +186,8 @@ high (burst is a rate right, not a quiet right):
- `quiet_mode = "toast"`: a desktop notification instead of audio, through - `quiet_mode = "toast"`: a desktop notification instead of audio, through
the system's own tools (Linux `notify-send`, macOS Notification Center, the system's own tools (Linux `notify-send`, macOS Notification Center,
Windows toast), best effort: if the tool is missing, only the log line Windows toast), best effort: if the tool is missing, only the log line
remains and `talk doctor` shows it under "Toast". remains and `talk doctor` shows it under "Toast". Try it with
`talk test --toast`.
- `now:true` **breaks out of quiet hours too** (a deliberate interruption). - `now:true` **breaks out of quiet hours too** (a deliberate interruption).
**Length and age:** two more laws against what prompts like to do, reading **Length and age:** two more laws against what prompts like to do, reading
+2 -1
View File
@@ -88,7 +88,7 @@ var commands = []command{
{ {
name: "test", name: "test",
short: "speak a test sentence", short: "speak a test sentence",
detail: "Speaks a fixed test sentence directly in this process (not via the daemon, even when muted) and shows which provider spoke and how long synthesis and playback took. --lang de|en picks the sentence, --provider/--voice pin like say.", detail: "Speaks a fixed test sentence directly in this process (not via the daemon, even when muted) and shows which provider spoke and how long synthesis and playback took. --lang de|en picks the sentence, --provider/--voice pin like say. --toast sends a test desktop notification instead (the path quiet_mode = toast uses).",
register: addTestFlags, register: addTestFlags,
handler: runTest, handler: runTest,
}, },
@@ -136,6 +136,7 @@ func addTestFlags(fs *flag.FlagSet) *sayOptions {
fs.StringVar(&o.lang, "lang", "", "language of the test sentence: de | en (default: configured lang)") fs.StringVar(&o.lang, "lang", "", "language of the test sentence: de | en (default: configured lang)")
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin") fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID") fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
fs.BoolVar(&o.toast, "toast", false, "send a test desktop notification instead of speaking")
return o return o
} }
+1
View File
@@ -33,6 +33,7 @@ type sayOptions struct {
// keeps one flag signature; say ignores them. // keeps one flag signature; say ignores them.
jsonOut bool jsonOut bool
probe bool probe bool
toast bool
} }
// runSay is the say entry point: collect the text from arguments or stdin, // runSay is the say entry point: collect the text from arguments or stdin,
+34 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/notify"
"code.m3ta.dev/m3tam3re/talk/internal/queue" "code.m3ta.dev/m3tam3re/talk/internal/queue"
"code.m3ta.dev/m3tam3re/talk/internal/speak" "code.m3ta.dev/m3tam3re/talk/internal/speak"
) )
@@ -18,11 +19,19 @@ var testPhrase = map[string]string{
"en": "This is a test announcement from talk. If you can hear this, everything works.", "en": "This is a test announcement from talk. If you can hear this, everything works.",
} }
// toastPhrase is the text of the test notification per language.
var toastPhrase = map[string]string{
"de": "Dies ist eine Testbenachrichtigung von talk. So sehen Ansagen in der Ruhezeit aus.",
"en": "This is a test notification from talk. This is what announcements look like during quiet hours.",
}
// runTest implements `talk test`: speaks the test sentence DIRECTLY in this // runTest implements `talk test`: speaks the test sentence DIRECTLY in this
// process (not via the daemon), so the command sees which tier really spoke // process (not via the daemon), so the command sees which tier really spoke
// and how long synthesis and playback took. Mute and quiet hours // and how long synthesis and playback took. Mute and quiet hours
// deliberately do not apply (the user is testing right now); an active mute // deliberately do not apply (the user is testing right now); an active mute
// is mentioned. // is mentioned. With --toast it sends a test desktop notification instead,
// through the same path quiet_mode = toast uses, so the notification setup
// can be checked without a quiet window and a daemon restart.
func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int { func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
if len(args) > 0 { if len(args) > 0 {
fmt.Fprintln(stderr, "talk test: no arguments expected (language: --lang de|en)") fmt.Fprintln(stderr, "talk test: no arguments expected (language: --lang de|en)")
@@ -40,6 +49,9 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
if lang == "" { if lang == "" {
lang = cfg.Lang lang = cfg.Lang
} }
if opts.toast {
return runToastTest(lang, stdout, stderr)
}
if dir, err := config.Dir(getenv); err == nil { if dir, err := config.Dir(getenv); err == nil {
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() { if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
fmt.Fprintln(stderr, "talk test: mute is on; the test plays anyway, normal announcements stay silent (talk unmute)") fmt.Fprintln(stderr, "talk test: mute is on; the test plays anyway, normal announcements stay silent (talk unmute)")
@@ -75,3 +87,24 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
} }
return exitOK return exitOK
} }
// runToastTest sends the test notification and reports the tool and the
// result. Exit 1 when no tool exists or the call fails.
func runToastTest(lang string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "toast: %s\n", notify.Describe())
if !notify.Available() {
fmt.Fprintln(stderr, "talk test: no notification tool found; install libnotify (notify-send) on Linux, macOS and Windows have it built in")
return exitFailure
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
start := time.Now()
if err := notify.Show(ctx, toastPhrase[lang]); err != nil {
fmt.Fprintf(stderr, "talk test: %v\n", err)
fmt.Fprintln(stderr, "hint: on Linux a notification daemon (mako, dunst, GNOME Shell, KDE) must be running for notify-send to show anything.")
return exitFailure
}
fmt.Fprintf(stdout, "sent: %s\n", time.Since(start).Round(time.Millisecond))
fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
return exitOK
}
+40
View File
@@ -2,6 +2,9 @@ package main
import ( import (
"bytes" "bytes"
"os"
"path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
@@ -35,3 +38,40 @@ func TestTestCommandInvalidLanguage(t *testing.T) {
t.Errorf("exit %d, stderr %q", code, errBuf.String()) t.Errorf("exit %d, stderr %q", code, errBuf.String())
} }
} }
// TestTestToast: --toast sends the test notification through notify-send
// (fake in PATH) with title and text and reports the tool; without a tool it
// is a clear error.
func TestTestToast(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fake notify-send exists on Linux only")
}
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "notify-send", `printf '%s\n' "$@" > "`+seen+`"`)
t.Setenv("PATH", dir)
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null"})
var out, errBuf bytes.Buffer
code := runCLI([]string{"test", "--toast", "--lang", "de"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("talk test --toast: exit %d, stderr %q", code, errBuf.String())
}
if !strings.Contains(out.String(), "toast: notify-send") || !strings.Contains(out.String(), "sent:") {
t.Errorf("stdout = %q", out.String())
}
b, err := os.ReadFile(seen)
if err != nil {
t.Fatalf("fake notify-send not called: %v", err)
}
if !strings.Contains(string(b), "talk\n") || !strings.Contains(string(b), "Testbenachrichtigung") {
t.Errorf("notify-send args = %q, want title talk and the German test text", b)
}
t.Setenv("PATH", t.TempDir())
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"test", "--toast"}, &out, &errBuf); code != exitFailure || !strings.Contains(errBuf.String(), "no notification tool") {
t.Errorf("without tool: exit %d, stderr %q", code, errBuf.String())
}
}