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 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.<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
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
+2 -1
View File
@@ -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
}
+1
View File
@@ -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,
+34 -1
View File
@@ -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
}
+40
View File
@@ -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())
}
}