Windows toasts cannot call back into the daemon, so the Cancel button opens a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon registers the talk: URL protocol for the current user (HKCU, no admin) and points it at `talk uri "%1"`; Windows starts that on the click, and `talk uri` posts to the new endpoint POST /cancel-item, which cancels only the announcement with that id (queue.CancelItem). The toast is tagged and removed again when the announcement ends. For now the toast is shown under Windows PowerShell's app identity, which unpackaged programs can use without a Start Menu shortcut. Verified on the Windows 11 VM: registry entry written by the daemon, Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops the playing announcement within two seconds, no toast errors in the log. Tests: `talk uri` against a real handler on a loopback port (playing vs waiting id, dead daemon, talk:test, garbage links), POST /cancel-item process test (only the playing item, waiting one plays next, 400 without id). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
139 lines
5.2 KiB
Go
139 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"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"
|
|
)
|
|
|
|
// testPhrase is the fixed test sentence per language.
|
|
var testPhrase = map[string]string{
|
|
"de": "Dies ist eine Testansage von talk. Wenn du das hörst, funktioniert alles.",
|
|
"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. 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)")
|
|
return exitUsage
|
|
}
|
|
if code, bad := validateSayOptions(opts, stderr); bad {
|
|
return code
|
|
}
|
|
cfg, err := config.Load(config.Options{Getenv: getenv})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
lang := opts.lang
|
|
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)")
|
|
}
|
|
}
|
|
if cfg.Audio.Sink == config.SinkNull {
|
|
fmt.Fprintln(stderr, "talk test: audio.sink = null; no sound will be produced")
|
|
}
|
|
|
|
engine, err := speak.New(cfg, getenv, stderr)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
out, err := engine.Speak(ctx, speak.Request{Text: testPhrase[lang], Lang: lang, Provider: opts.provider, Voice: opts.voice})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk test: %v\n", err)
|
|
fmt.Fprintln(stderr, "hint: `talk doctor` shows which tier is missing and why.")
|
|
return exitFailure
|
|
}
|
|
fmt.Fprintf(stdout, "provider: %s\n", out.Provider)
|
|
fmt.Fprintf(stdout, "language: %s\n", lang)
|
|
switch {
|
|
case out.Discarded:
|
|
fmt.Fprintln(stdout, "playback: discarded (audio.sink = null)")
|
|
case out.Direct:
|
|
fmt.Fprintf(stdout, "duration: %s (direct speech via the OS)\n", out.Synthesis.Round(time.Millisecond))
|
|
default:
|
|
fmt.Fprintf(stdout, "synthesis: %s (%d bytes)\n", out.Synthesis.Round(time.Millisecond), out.Bytes)
|
|
fmt.Fprintf(stdout, "playback: %s\n", out.Playback.Round(time.Millisecond))
|
|
}
|
|
return exitOK
|
|
}
|
|
|
|
// runToastTest shows the test notification the way a playing announcement
|
|
// gets one (with a Cancel button where supported), waits a few seconds for a
|
|
// click and reports what happened. 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 path found; on Linux install libnotify (notify-send) or run a notification daemon, macOS and Windows have one built in")
|
|
return exitFailure
|
|
}
|
|
label := "Cancel"
|
|
if lang == "de" {
|
|
label = "Abbrechen"
|
|
}
|
|
clicked := make(chan struct{}, 1)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
start := time.Now()
|
|
closeFn, err := notify.Announce(ctx, notify.Announcement{Text: toastPhrase[lang], CancelLabel: label, CancelURI: "talk:test", Tag: "test"}, func() {
|
|
select {
|
|
case clicked <- struct{}{}:
|
|
default:
|
|
}
|
|
})
|
|
if 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.")
|
|
return exitFailure
|
|
}
|
|
fmt.Fprintf(stdout, "sent: %s\n", time.Since(start).Round(time.Millisecond))
|
|
if !notify.SupportsCancel() {
|
|
fmt.Fprintln(stdout, "cancel: not available on this platform (notification without button)")
|
|
fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
|
|
return exitOK
|
|
}
|
|
fmt.Fprintf(stdout, "cancel: button %q shown; waiting up to %s for a click …\n", label, toastWait)
|
|
select {
|
|
case <-clicked:
|
|
fmt.Fprintln(stdout, "cancel: clicked, works")
|
|
case <-time.After(toastWait):
|
|
fmt.Fprintln(stdout, "cancel: not clicked (that is fine); closing the notification")
|
|
}
|
|
closeFn()
|
|
return exitOK
|
|
}
|
|
|
|
// toastWait is how long `talk test --toast` waits for a click on Cancel.
|
|
const toastWait = 8 * time.Second
|