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)
```