branch: externals/greader
commit 5f79301dc93dbe875e5e21b62a2d4a90b9e28a2c
Author: Michelangelo Rodriguez <[email protected]>
Commit: Michelangelo Rodriguez <[email protected]>

    feat(backends): Add completing-read voice selection and save-voice 
persistence
    
    * greader-mac.el (greader-mac-set-voice): Switch from read-string to
    completing-read; return plain voice name instead of CLI flag.
    (greader-mac): Handle 'lang with optional arg to decouple state-setting
    from flag generation; add 'set-voice returning voice name via
    call-interactively; add 'save-voice persisting greader-mac-voice via
    customize-save-variable.
    * greader-espeak.el (greader--espeak-list-voices): New function; parses
    espeak --list-voices output and returns deduplicated language codes.
    (greader-espeak-set-language): Fix inconsistency: remove spurious space
    in concat when called with arg (was "-v it", now "-vit" like no-arg form).
    (greader-espeak): Add 'set-voice using completing-read with
    greader--espeak-list-voices; add 'save-voice persisting
    greader-espeak-language via customize-save-variable.
    * greader.el (greader-set-language): With prefix arg, call 'save-voice
    to persist the selected voice/language to custom-file.
    * greader-backend-tests.el: New file; ERT tests for backend CLI
    generation after voice/language selection (greader-mac,
    greader-espeak) and for save-voice persistence.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 CLAUDE.md                |  12 ++++++
 greader-backend-tests.el | 105 +++++++++++++++++++++++++++++++++++++++++++++++
 greader-espeak.el        |  19 ++++++++-
 greader-mac.el           |  23 +++++++----
 greader.el               |   6 ++-
 readme.md                |   9 ++--
 6 files changed, 160 insertions(+), 14 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 19f0e309f9..870ab6bd1b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,6 +24,7 @@
 | `greader-audiobook.el` | Convert buffers to audio files (WAV/MP3/etc.) |
 | `greader-translate.el` | Translate text before reading (via 
google-translate) |
 | `greader-dict-tests.el` | ERT tests for greader-dict |
+| `greader-backend-tests.el` | ERT tests for backend CLI generation after 
voice/language selection |
 | `greader-autoloads.el` | Auto-generated autoloads |
 | `greader-pkg.el` | Auto-generated package descriptor |
 | `readme.md` | User documentation |
@@ -62,6 +63,17 @@ Each backend is a function that responds to `command` 
symbols:
 - `extra` — additional flags
 - `get-language` — return current language string
 - `get-rate` — return current rate as a number
+- `set-voice` — interactive voice/language selection via `completing-read`; 
returns the
+  **plain voice or language name** (e.g. `"Alex"` or `"it"`), **not** a CLI 
flag; returns
+  `'not-implemented` if the backend has no enumerable voice/language list.
+  `greader-set-language` calls this first; if `'not-implemented`, falls back to
+  `read-string`. **Supported by**: mac (`greader--mac-get-voices`), espeak
+  (`greader--espeak-list-voices`). **Not supported**: speechd, piper.
+- `save-voice` — persist the voice/language name passed as `arg` to 
`custom-file` via
+  `customize-save-variable`; makes the selection the new global default across 
sessions.
+  Called by `greader-set-language` when invoked with a prefix argument (`C-u 
C-r l`).
+  **Supported by**: mac (saves `greader-mac-voice`), espeak (saves
+  `greader-espeak-language`). **Not supported**: speechd, piper.
 - `audio-write` — write WAV to file for audiobook generation; arg is `(list 
TEXT FILENAME)`;
   returns exit code (0 = success) or `'not-implemented` if unsupported.
   **Supported by**: espeak, mac, piper. **Not supported**: speechd.
diff --git a/greader-backend-tests.el b/greader-backend-tests.el
new file mode 100644
index 0000000000..2c66f62871
--- /dev/null
+++ b/greader-backend-tests.el
@@ -0,0 +1,105 @@
+;; greader-backend-tests.el --- ERT tests for greader TTS backend CLI 
generation  -*- lexical-binding: t; -*-
+;; Copyright (C) 2017-2026  Free Software Foundation, Inc.
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;; Tests verifying that TTS backend functions produce the correct CLI
+;; command-line flags after voice/language selection via `completing-read'.
+
+;;; Code:
+(require 'ert)
+(require 'cl-lib)
+(require 'greader-mac)
+(require 'greader-espeak)
+
+;;; greader-mac
+
+(ert-deftest greader-mac-voice-selection-cmdline ()
+  "After `completing-read' voice selection, `'lang' returns the correct CLI 
flag.
+Simulates the full flow used by `greader-set-language':
+  1. `'set-voice' opens completing-read and returns the plain voice name.
+  2. `'lang VOICE' sets the buffer-local voice and returns the \"-v\" flag.
+  3. `'lang nil' (as called by `greader-build-args') also returns the flag."
+  (with-temp-buffer
+    (setq-local greader-mac-voice nil)
+    (cl-letf (((symbol-function 'completing-read)
+               (lambda (_prompt _coll &rest _) "Alex"))
+              ((symbol-function 'greader--mac-get-voices)
+               (lambda () '("system" "Alex" "Alice"))))
+      (let ((voice (greader-mac 'set-voice nil)))
+        ;; set-voice must return the plain voice name
+        (should (equal voice "Alex"))
+        ;; passing that name to 'lang must set the voice and return the flag
+        (should (equal (greader-mac 'lang voice) "-vAlex"))
+        ;; 'lang without arg (greader-build-args path) must return the same 
flag
+        (should (equal (greader-mac 'lang nil) "-vAlex"))))))
+
+(ert-deftest greader-mac-system-voice-clears-flag ()
+  "Selecting \"system\" voice clears the voice flag (nil means system 
default)."
+  (with-temp-buffer
+    (setq-local greader-mac-voice "Alex")
+    (cl-letf (((symbol-function 'completing-read)
+               (lambda (_prompt _coll &rest _) "system"))
+              ((symbol-function 'greader--mac-get-voices)
+               (lambda () '("system" "Alex" "Alice"))))
+      (let ((voice (greader-mac 'set-voice nil)))
+        (should (equal voice "system"))
+        (should (null (greader-mac 'lang voice)))
+        (should (null (greader-mac 'lang nil)))))))
+
+;;; greader-espeak
+
+(ert-deftest greader-espeak-voice-selection-cmdline ()
+  "After `completing-read' language selection, `'lang' returns the correct CLI 
flag.
+Simulates the full flow used by `greader-set-language':
+  1. `'set-voice' opens completing-read and returns the plain language code.
+  2. `'lang LANG' sets the buffer-local language and returns the \"-v\" flag.
+  3. `'lang nil' (as called by `greader-build-args') also returns the flag."
+  (with-temp-buffer
+    (setq-local greader-espeak-language "en")
+    (cl-letf (((symbol-function 'completing-read)
+               (lambda (_prompt _coll &rest _) "it"))
+              ((symbol-function 'greader--espeak-list-voices)
+               (lambda () '("en" "fr" "it" "de"))))
+      (let ((lang (greader-espeak 'set-voice nil)))
+        ;; set-voice must return the plain language code
+        (should (equal lang "it"))
+        ;; passing that code to 'lang must set the language and return the flag
+        (should (equal (greader-espeak 'lang lang) "-vit"))
+        ;; 'lang without arg (greader-build-args path) must return the same 
flag
+        (should (equal (greader-espeak 'lang nil) "-vit"))))))
+
+;;; save-voice persistence
+
+(ert-deftest greader-mac-save-voice-calls-customize ()
+  "`'save-voice' calls `customize-save-variable' with the correct variable and 
value."
+  (let (saved-var saved-val)
+    (cl-letf (((symbol-function 'customize-save-variable)
+               (lambda (var val &rest _) (setq saved-var var saved-val val))))
+      (greader-mac 'save-voice "Alex"))
+    (should (eq saved-var 'greader-mac-voice))
+    (should (equal saved-val "Alex"))))
+
+(ert-deftest greader-espeak-save-voice-calls-customize ()
+  "`'save-voice' calls `customize-save-variable' with the correct variable and 
value."
+  (let (saved-var saved-val)
+    (cl-letf (((symbol-function 'customize-save-variable)
+               (lambda (var val &rest _) (setq saved-var var saved-val val))))
+      (greader-espeak 'save-voice "it"))
+    (should (eq saved-var 'greader-espeak-language))
+    (should (equal saved-val "it"))))
+
+(provide 'greader-backend-tests)
+;;; greader-backend-tests.el ends here
diff --git a/greader-espeak.el b/greader-espeak.el
index 6da6f38a0b..8c23800d1c 100644
--- a/greader-espeak.el
+++ b/greader-espeak.el
@@ -65,7 +65,20 @@ LANG must be recognized by espeak or espeak-ng."
       (concat "-v" greader-espeak-language)
     (progn
       (setq-local greader-espeak-language lang)
-      (concat "-v " lang))))
+      (concat "-v" lang))))
+
+(defun greader--espeak-list-voices ()
+  "Return a list of language codes available in espeak."
+  (with-temp-buffer
+    (call-process greader-espeak-executable-name nil t nil "--list-voices")
+    (goto-char (point-min))
+    (forward-line 1)                    ; skip header line
+    (let (voices)
+      (while (not (eobp))
+        (when (looking-at "[ \t]*[0-9]+[ \t]+\\([[:alnum:]_-]+\\)")
+          (push (match-string 1) voices))
+        (forward-line 1))
+      (delete-dups (nreverse voices)))))
 
 (defvar-local  greader-espeak--punctuation-ring (make-ring 2))
 (ring-insert greader-espeak--punctuation-ring "yes")
@@ -108,6 +121,10 @@ ARG is applied depending on the command."
        (if greader-espeak-punctuation
            "--punct"
          nil))))
+    ('set-voice
+     (completing-read "Language: " (greader--espeak-list-voices) nil t))
+    ('save-voice
+     (customize-save-variable 'greader-espeak-language arg))
     ('get-language
      greader-espeak-language)
     ('get-rate
diff --git a/greader-mac.el b/greader-mac.el
index 9bf300aa57..5ff22de26f 100644
--- a/greader-mac.el
+++ b/greader-mac.el
@@ -51,15 +51,14 @@ nil means to use the system voice."
       (concat "-r" (number-to-string rate)))))
 
 (defun greader-mac-set-voice (voice)
-  "Set specified VOICE for `say'.
-When called interactively, this function reads a string from the minibuffer
-providing completion."
+  "Set specified VOICE for `say' in the current buffer.
+When called interactively, presents available voices via `completing-read'.
+Returns the plain voice name (e.g. \"Alex\"), not a CLI flag."
   (interactive
-   (list (read-string "Voice: " nil nil (greader--mac-get-voices))))
-  (when voice
-    (setq-local greader-mac-voice
-                (if (string-equal "system" voice) nil voice)))
-  (when greader-mac-voice (concat "-v" greader-mac-voice)))
+   (list (completing-read "Voice: " (greader--mac-get-voices) nil t)))
+  (setq-local greader-mac-voice
+              (if (string-equal "system" voice) nil voice))
+  voice)
 
 ;;;###autoload
 (defun greader-mac (command &optional arg)
@@ -73,9 +72,15 @@ COMMAND must be a string suitable for `make-process'."
     ('executable
      greader-mac-executable-name)
     ('lang
-     (greader-mac-set-voice arg))
+     (progn
+       (when arg
+         (setq-local greader-mac-voice
+                     (if (string-equal "system" arg) nil arg)))
+       (when greader-mac-voice (concat "-v" greader-mac-voice))))
     ('set-voice
      (call-interactively #'greader-mac-set-voice))
+    ('save-voice
+     (customize-save-variable 'greader-mac-voice arg))
     ('rate
      (cond
       ((equal arg 'value)
diff --git a/greader.el b/greader.el
index 9328641e23..3501102963 100644
--- a/greader.el
+++ b/greader.el
@@ -758,7 +758,9 @@ Optional argument STRING contains the string passed to
 (defun greader-set-language (lang)
   "Set language of tts.
 LANG must be in ISO code, for example `en' for English or `fr' for
-French."
+French.
+With a prefix argument, save the selected voice/language as the new
+global default (written to `custom-file' via `customize-save-variable')."
   (interactive
    (list
     (let ((result (greader-call-backend 'set-voice nil)))
@@ -766,6 +768,8 @@ French."
          (read-string "Set language to: ")
        result))))
   (greader-call-backend 'lang lang)
+  (when current-prefix-arg
+    (greader-call-backend 'save-voice lang))
   (run-hooks 'greader-after-change-language-hook))
 
 (defun greader-set-punctuation (flag)
diff --git a/readme.md b/readme.md
index 98d23d3e72..ca95f7de07 100644
--- a/readme.md
+++ b/readme.md
@@ -58,7 +58,7 @@ If desired, you can change the prefix. See the command 
`greader-set-map-prefix`.
 |---|---|---|
 | `C-r <spc>` | `greader-read` | Start reading from point. |
 | `SPC` | `greader-stop` | Stop reading (only when you are in 
`greader-reading-mode', which happens when you call `greader-read').|
-| `C-r l` | `greader-set-language` | Set the language for the TTS engine. |
+| `C-r l` | `greader-set-language` | Set the language/voice for the TTS 
engine. Backends that support voice enumeration (mac, espeak) present a 
`completing-read` prompt. With a prefix argument (`C-u C-r l`), the selection 
is saved as the new global default to `custom-file`. |
 | `C-r b` | `greader-change-backend` | Cycle through available backends. |
 | `C-r t` | `greader-timer-mode` | Toggle the reading timer. |
 | `C-r s` | `greader-tired-mode` | Toggle tired/relax mode. |
@@ -322,11 +322,14 @@ Uses the built-in macOS `say` command. No external 
installation required.
 
 | Variable | Default | Description |
 |---|---|---|
-| `greader-mac-voice` | `nil` | Voice name to use (e.g., `"Samantha"`). `nil` 
uses the system default. |
+| `greader-mac-voice` | `nil` | Voice name to use (e.g., `"Alex"`). `nil` uses 
the system default. |
 | `greader-mac-rate` | `200` | Speech rate in words per minute. |
 
+Use `C-r l` to select a voice interactively from the list of all voices 
installed on the
+system. Use `C-u C-r l` to also save the choice as the new global default.
+
 ```emacs-lisp
-(setq greader-mac-voice "Samantha")
+(setq greader-mac-voice "Alex")
 (setq greader-mac-rate 180)
 ```
 

Reply via email to