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:
1 parent
1b971bc3f6
commit
6f3e7c9f35
5 files changed
+83
-5
No files matched your search
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user