From 6f3e7c9f35b0efc580f389a0f7b4dc1f1a392dc7 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 24 Oct 2026 11:55:24 +0200 Subject: [PATCH] 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 --- README.md | 9 ++++++--- cmd/talk/cli.go | 3 ++- cmd/talk/say.go | 1 + cmd/talk/testcmd.go | 35 ++++++++++++++++++++++++++++++++++- cmd/talk/testcmd_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6aae326..da7c0d2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ dependencies. | `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 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 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 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 - 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, OpenAI, Edge neural voices per locale) as a table; `*` marks the voice talk uses without `--voice` (config `voice..` 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 the system's own tools (Linux `notify-send`, macOS Notification Center, 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). **Length and age:** two more laws against what prompts like to do, reading diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index 142ed69..3646057 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -88,7 +88,7 @@ var commands = []command{ { name: "test", 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, 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.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin") 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 } diff --git a/cmd/talk/say.go b/cmd/talk/say.go index 80687c0..57320a3 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -33,6 +33,7 @@ type sayOptions struct { // keeps one flag signature; say ignores them. jsonOut bool probe bool + toast bool } // runSay is the say entry point: collect the text from arguments or stdin, diff --git a/cmd/talk/testcmd.go b/cmd/talk/testcmd.go index 758252f..a0cdaea 100644 --- a/cmd/talk/testcmd.go +++ b/cmd/talk/testcmd.go @@ -8,6 +8,7 @@ import ( "time" "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/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.", } +// 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 // process (not via the daemon), so the command sees which tier really spoke // and how long synthesis and playback took. Mute and quiet hours // 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 { if len(args) > 0 { 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 == "" { lang = cfg.Lang } + if opts.toast { + return runToastTest(lang, stdout, stderr) + } if dir, err := config.Dir(getenv); err == nil { 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)") @@ -75,3 +87,24 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri } 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 +} diff --git a/cmd/talk/testcmd_test.go b/cmd/talk/testcmd_test.go index e6f7304..1d0db41 100644 --- a/cmd/talk/testcmd_test.go +++ b/cmd/talk/testcmd_test.go @@ -2,6 +2,9 @@ package main import ( "bytes" + "os" + "path/filepath" + "runtime" "strings" "testing" ) @@ -35,3 +38,40 @@ func TestTestCommandInvalidLanguage(t *testing.T) { 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()) + } +}