disable voice reader by default
[amread-mode.git] / amread-mode.el
blobac20116d9b189891883a9ceecced30b7b042df10
1 ;;; amread-mode.el --- A minor mode helper user speed-reading -*- lexical-binding: t; -*-
3 ;;; Time-stamp: <2020-06-23 23:44:49 stardiviner>
5 ;; Authors: stardiviner <numbchild@gmail.com>
6 ;; Package-Requires: ((emacs "24.3") (cl-lib "0.6.1") (pyim "5.2.8"))
7 ;; Package-Version: 0.1
8 ;; Keywords: wp
9 ;; homepage: https://repo.or.cz/amread-mode.git
11 ;; amread-mode is free software; you can redistribute it and/or modify it
12 ;; under the terms of the GNU General Public License as published by
13 ;; the Free Software Foundation; either version 3, or (at your option)
14 ;; any later version.
16 ;; amread-mode is distributed in the hope that it will be useful, but WITHOUT
17 ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
18 ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
19 ;; License for more details.
22 ;; You should have received a copy of the GNU General Public License
23 ;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
25 ;;; Commentary:
26 ;;;
27 ;;; Usage
28 ;;;
29 ;;; 1. Launch amread-mode with command `amread-mode'.
30 ;;; 2. Stop amread-mode by pressing [q].
32 ;;; Code:
33 (require 'cl-lib)
34 (require 'pyim)
37 (defcustom amread-word-speed 3.0
38 "Read words per second."
39 :type 'float
40 :safe #'floatp
41 :group 'amread-mode)
43 (defcustom amread-line-speed 4.0
44 "Read one line using N seconds in average."
45 :type 'float
46 :safe #'floatp
47 :group 'amread-mode)
49 (defcustom amread-scroll-style nil
50 "Set amread auto scroll style by word or line."
51 :type '(choice (const :tag "scroll by word" word)
52 (const :tag "scroll by line" line))
53 :safe #'symbolp
54 :group 'amread-mode)
56 (defcustom amread-voice-reader-enabled nil
57 "The initial state of voice reader."
58 :type 'boolean
59 :safe #'booleanp
60 :group 'amread-mode)
62 (defcustom amread-voice-reader-command
63 (cl-case system-type
64 (darwin "say")
65 (gnu/linux (or (executable-find "espeak") (executable-find "festival")))
66 (windows-nt )) ; TODO
67 "The command for reading text."
68 :type 'string
69 :safe #'stringp
70 :group 'amread-mode)
72 (defcustom amread-voice-reader-command-options ""
73 "Specify options for voice reader command."
74 :type 'string
75 :safe #'stringp
76 :group 'amread-mode)
78 (defcustom amread-voice-reader-language 'chinese
79 "Specifiy default language for voice reader."
80 :type 'symbol
81 :safe #'symbolp
82 :group 'amread-mode)
84 (defface amread-highlight-face
85 '((t :foreground "black" :background "ForestGreen"))
86 "Face for amread-mode highlight."
87 :group 'amread-mode)
89 (defvar amread--timer nil)
90 (defvar amread--current-position nil)
91 (defvar amread--overlay nil)
93 (defvar amread--voice-reader-proc-finished nil
94 "A process status variable indicate whether voice reader finished reading.
95 It has three status values:
96 - 'not-started :: process not started
97 - 'running :: process still running
98 - 'finished :: process finished")
100 (defmacro amread--voice-reader-status-wrapper (body)
101 "A wrapper macro for detecting voice reader process status and execute BODY."
102 `(if amread-voice-reader-enabled
103 ;; wait for process finished, then jump to next word.
104 (cl-case amread--voice-reader-proc-finished
105 (not-started ,body)
106 (running (ignore))
107 (finished ,body)
108 (t (setq amread--voice-reader-proc-finished 'not-started)))
109 ,body))
111 ;; (macroexpand-1
112 ;; '(amread--voice-reader-status-wrapper (amread--word-update)))
114 (defun amread--voice-reader-read-text (text)
115 "Read TEXT with voice command-line tool."
116 (when (and amread-voice-reader-enabled
117 (not (null text))
118 (not (string-empty-p text)))
119 (setq amread--voice-reader-proc-finished 'running)
120 (cl-case system-type
121 (darwin
122 (or (amread--voice-reader-read-text-with-tts text)
123 (amread--voice-reader-read-text-with-say text)))
124 (t (amread--voice-reader-read-text-with-tts text)))))
126 (defun amread--voice-reader-run-python-code-to-string (&rest python-code-lines)
127 "Run PYTHON-CODE-LINES through Python interpreter result to string."
128 (let ((python-interpreter (or (executable-find "python3") python-interpreter)))
129 (shell-command-to-string
130 (format "%s %s"
131 python-interpreter
132 (concat " -c "
133 ;; solve double quote character issue.
134 "\"" (string-replace "\"" "\\\"" (string-join python-code-lines "\n")) "\"")))))
136 ;; (print
137 ;; (amread--voice-reader-run-python-code-to-string
138 ;; "import numpy as np"
139 ;; "print(np.arange(6))"
140 ;; "print(\"blah blah\")"
141 ;; "print('{}'.format(3))"))
143 (defun amread--voice-reader-run-python-file-to-string (python-code-file)
144 "Run PYTHON-CODE-FILE through Python interpreter result to string."
145 (let ((python-interpreter (or (executable-find "python3") python-interpreter)))
146 (shell-command-to-string
147 (format "%s %s" python-interpreter python-code-file))))
149 (defun amread--voice-reader-run-python-code-in-repl (&rest python-code-lines)
150 "Run PYTHON-CODE-LINES through Python REPL process result to strings list."
151 (let ((python-interpreter (or (executable-find "python3") python-interpreter)))
152 (run-python)
153 ;; `process-send-string' alias `send-string'
154 ;; `python-shell-send-string', `python-shell-internal-send-string', `python-shell-send-string-no-output'
155 (dolist (line python-code-lines
156 result)
157 ;; assign last eval result to `dolist' binding `result'.
158 (setf result (python-shell-send-string-no-output line)))))
160 ;; (amread--voice-reader-run-python-code-in-repl
161 ;; "import sys"
162 ;; "print(sys.path)"
163 ;; "sys.path")
165 (defvar amread--voice-reader-engine-initialized nil)
167 ;; invoke cross-platform TTS Speech API through Python library "pyttsx3".
168 (defun amread--voice-reader-read-text-with-tts (text)
169 "Read TEXT with cross-platform TTS Speech API through Python library 'pyttsx3'."
170 ;; keep instance of engine to avoid repeat creating performance issue.
171 (unless (eq amread--voice-reader-engine-initialized "True")
172 (setq amread--voice-reader-engine-initialized
173 (amread--voice-reader-run-python-code-in-repl
174 "import pyttsx3"
175 "engine = pyttsx3.init()"
176 "True")))
177 (amread--voice-reader-run-python-code-in-repl
178 (format "engine.say(\"%s\")" text)
179 "engine.runAndWait()"))
181 ;; (amread--voice-reader-read-text-with-tts "happy")
183 (defun amread--voice-reader-read-text-with-say (text)
184 "Read TEXT with macOS command `say'."
185 ;; detect language and switch language/voice.
186 (let ((language (amread-voice-reader-switch-language-voice)))
187 (cl-case language
188 (chinese
189 (setq amread-voice-reader-command-options "--voice=Ting-Ting")
190 (message "[amread] voice reader switched to Chinese language/voice."))
191 (english
192 (setq amread-voice-reader-command-options "--voice=Ava")
193 (message "[amread] voice reader switched to English language/voice."))
195 (let ((voice (completing-read "[amread] Select language/voice: " '("Ting-Ting" "Ava"))))
196 (setq amread-voice-reader-command-options (format "--voice=%s" voice))
197 (message "[amread] voice reader switched to language/voice <%s>." voice)))))
199 ;; Synchronous Processes
200 ;; (call-process-shell-command
201 ;; amread-voice-reader-command
202 ;; nil nil nil
203 ;; amread-voice-reader-command-options
204 ;; (shell-quote-argument text))
206 ;; Async Process
207 (make-process
208 :name "amread-voice-reader"
209 :command (list amread-voice-reader-command amread-voice-reader-command-options text)
210 :sentinel (lambda (proc event)
211 (if (string= event "finished\n")
212 (setq amread--voice-reader-proc-finished 'finished)))
213 :buffer " *amread-voice-reader*"
214 :stderr " *amread-voice-reader*"))
216 (defun amread--word-update ()
217 "Scroll forward by word as step."
218 (let* ((begin (point))
219 ;; move point forward. NOTE This forwarding must be here before moving overlay forward.
220 (_length (+ (skip-chars-forward "^\s\t\n—") (skip-chars-forward "—")))
221 (end (point))
222 (word (buffer-substring-no-properties begin end)))
223 (if (eobp)
224 (progn
225 (amread-mode -1)
226 (setq amread--current-position nil))
227 ;; create the overlay if does not exist
228 (unless amread--overlay
229 (setq amread--overlay (make-overlay begin end)))
230 ;; move overlay forward
231 (when amread--overlay
232 (move-overlay amread--overlay begin end))
233 (setq amread--current-position (point))
234 (overlay-put amread--overlay 'face 'amread-highlight-face)
235 ;; read word text
236 (amread--voice-reader-read-text word)
237 (skip-chars-forward "\s\t\n—")
238 ;; when in nov.el ebook, auto navigate to next page.
239 (when (and (eobp) (eq major-mode 'nov-mode))
240 (nov-next-document)
241 (message "[amread] nov.el next page.")))))
243 (defun amread--line-update ()
244 "Scroll forward by line as step."
245 (let* ((line-begin (line-beginning-position))
246 (line-end (line-end-position))
247 (line-text (buffer-substring-no-properties line-begin line-end)))
248 (if (eobp) ; reached end of buffer.
249 (progn
250 (amread-mode -1)
251 (setq amread--current-position nil))
252 ;; create line overlay to highlight current reading line.
253 (unless amread--overlay
254 (setq amread--overlay (make-overlay line-begin line-end)))
255 ;; scroll down line
256 (when amread--overlay
257 (move-overlay amread--overlay line-begin line-end))
258 (overlay-put amread--overlay 'face 'amread-highlight-face)
259 ;; read line text
260 (amread--voice-reader-read-text line-text)
261 (forward-line 1)
262 ;; when in nov.el ebook, auto navigate to next page.
263 (when (and (eobp) (eq major-mode 'nov-mode))
264 (nov-next-document)
265 (message "[amread] nov.el next page.")))))
267 (defun amread--update ()
268 "Update and scroll forward under Emacs timer."
269 (cl-case amread-scroll-style
270 (word
271 (amread--voice-reader-status-wrapper (amread--word-update)))
272 (line
273 (amread--voice-reader-status-wrapper (amread--line-update))
274 ;; Auto modify the running timer REPEAT seconds based on next line words length.
275 (let* ((next-line-words (amread--get-next-line-words)) ; for English
276 ;; TODO: Add Chinese text logic.
277 ;; (next-line-length (amread--get-next-line-length)) ; for Chinese
278 (amread--next-line-pause-secs (truncate (/ next-line-words amread-word-speed))))
279 (when (> amread--next-line-pause-secs 0)
280 (setf (timer--repeat-delay amread--timer) amread--next-line-pause-secs))))
281 (t (user-error "Seems amread-mode is not normally started or not running."))))
283 (defun amread--scroll-style-ask ()
284 "Ask which scroll style to use."
285 (let ((style (intern (completing-read "amread-mode scroll style: " '("word" "line")))))
286 (setq amread-scroll-style style)
287 style))
289 (defun amread--get-line-words (&optional pos)
290 "Get the line words of position."
291 (save-excursion
292 (and pos (goto-char pos))
293 (beginning-of-line)
294 (count-words (line-end-position) (line-beginning-position))))
296 (defun amread--get-next-line-words ()
297 "Get the next line words."
298 (amread--get-line-words (save-excursion (forward-line) (point))))
300 (defun amread--get-line-length (&optional pos)
301 "Get the line length of position."
302 (save-excursion
303 (and pos (goto-char pos))
304 (- (line-end-position) (line-beginning-position))))
306 (defun amread--get-next-line-length ()
307 "Get the next line length."
308 (amread--get-line-words (save-excursion (forward-line) (point))))
310 ;;;###autoload
311 (defun amread-start ()
312 "Start / resume amread."
313 (interactive)
314 (read-only-mode 1)
315 ;; if quit `amread--scroll-style-ask', then don't enable `amread-mode'.
316 (or amread-scroll-style (amread--scroll-style-ask))
317 (setq amread--voice-reader-proc-finished 'not-started)
318 ;; select language
319 (setq amread-voice-reader-language
320 (intern
321 (completing-read "[amread] Select language: " '("chinese" "english"))))
322 ;; select scroll style
323 (if (null amread-scroll-style)
324 (user-error "User quited entering amread-mode.")
325 ;; resume from paused position
326 (cl-case amread-scroll-style
327 (word
328 (when amread--current-position
329 (goto-char amread--current-position))
330 (setq amread--timer
331 (run-with-timer 0 (/ 1.0 amread-word-speed) #'amread--update)))
332 (line
333 (when amread--current-position
334 (goto-char (point-min))
335 (forward-line amread--current-position))
336 (setq amread--timer
337 (run-with-timer 1 amread-line-speed #'amread--update)))
338 (t (user-error "Seems amread-mode is not normally started because of not selecting scroll style OR just not running.")))
339 ;; enable hydra
340 (hydra-amread/body)
341 (message "[amread] start reading...")))
343 ;;;###autoload
344 (defun amread-stop ()
345 "Stop amread."
346 (interactive)
347 (when amread--timer
348 (cancel-timer amread--timer)
349 (setq amread--timer nil)
350 (when amread--overlay
351 (delete-overlay amread--overlay)))
352 (setq amread-scroll-style nil)
353 (read-only-mode -1)
354 (hydra-keyboard-quit)
355 (message "[amread] stopped."))
357 ;;;###autoload
358 (defun amread-pause-or-resume ()
359 "Pause or resume amread."
360 (interactive)
361 (if amread--timer
362 (amread-stop)
363 (amread-start)))
365 ;;;###autoload
366 (defun amread-mode-quit ()
367 "Disable `amread-mode'."
368 (interactive)
369 (amread-mode -1)
370 (hydra-keyboard-quit))
372 ;;;###autoload
373 (defun amread-speed-up ()
374 "Speed up `amread-mode'."
375 (interactive)
376 (setq amread-word-speed (cl-incf amread-word-speed 0.2))
377 (message "[amread] word speed increased -> %s" amread-word-speed))
379 ;;;###autoload
380 (defun amread-speed-down ()
381 "Speed down `amread-mode'."
382 (interactive)
383 (setq amread-word-speed (cl-decf amread-word-speed 0.2))
384 (message "[amread] word speed decreased -> %s" amread-word-speed))
386 ;;;###autoload
387 (defun amread-voice-reader-toggle ()
388 "Toggle text voice reading."
389 (interactive)
390 (if amread-voice-reader-enabled
391 (progn
392 (setq amread-voice-reader-enabled nil)
393 (message "[amread] voice reader disabled."))
394 (setq amread-voice-reader-enabled t)
395 (message "[amread] voice reader enabled.")))
397 (defun amread--voice-reader-detect-language (&optional string)
398 "Detect text language."
399 ;; Return t if STRING is a Chinese string.
400 (if-let ((string (or string (word-at-point))))
401 (cond
402 ;; `pyim-probe-auto-english'
403 ((null (pyim-probe-dynamic-english))
404 'chinese)
405 ((pyim-probe-dynamic-english)
406 'english)
407 ((string-match (format "\\cC\\{%s\\}" (length string)) string)
408 'chinese))
409 nil))
411 ;; (amread--voice-reader-detect-language "测试")
412 ;; (amread--voice-reader-detect-language "测试test")
414 ;;;###autoload
415 (defun amread-voice-reader-switch-language-voice (&optional language)
416 "Switch voice reader LANGUAGE or voice."
417 (interactive "P")
418 (let ((language (or language
419 (when (called-interactively-p 'interactive)
420 (intern (completing-read "[amread] Select language: " '("chinese" "english"))))
421 amread-voice-reader-language
422 (amread--voice-reader-detect-language))))
423 language))
425 ;;;###autoload
426 (defun amread-voice-reader-read-buffer ()
427 "Read current buffer text without timer highlight updating."
428 (interactive)
429 ;; loop over all lines of buffer.
430 (save-excursion
431 (save-restriction
432 (widen)
433 (goto-char (point-min))
434 (while (not (eobp))
435 (let* ((line-begin (line-beginning-position))
436 (line-end (line-end-position))
437 (line-text (buffer-substring-no-properties line-begin line-end)))
438 ;; line processiqng
439 (let ((amread-voice-reader-enabled t))
440 (amread--voice-reader-status-wrapper
441 (amread--voice-reader-read-text line-text)))
442 (forward-line 1))))))
444 (defvar amread-mode-map
445 (let ((map (make-sparse-keymap)))
446 (define-key map (kbd "q") #'amread-mode-quit)
447 (define-key map (kbd "SPC") #'amread-pause-or-resume)
448 (define-key map [remap keyboard-quit] #'amread-mode-quit)
449 (define-key map (kbd "+") #'amread-speed-up)
450 (define-key map (kbd "-") #'amread-speed-down)
451 (define-key map (kbd "v") #'amread-voice-reader-toggle)
452 (define-key map (kbd "L") #'amread-voice-reader-switch-language-voice)
453 (define-key map (kbd ".") #'hydra-amread/body)
454 map)
455 "Keymap for `amread-mode' buffers.")
457 ;;;###autoload
458 (defhydra hydra-amread (:color green :hint nil :exit nil)
460 ^Control^ ^Adjust When Reading^
461 ^------------------^ ^-------------------------^
462 _SPC_: pause/resume _+_: speed up
463 _q_: quit _-_: speed down
464 ^ ^ _v_: toggle voice reader
465 ^ ^ _L_: switch language/voice
467 ("SPC" amread-pause-or-resume :color blue)
468 ("q" amread-mode-quit :color red)
469 ("+" amread-speed-up :color blue)
470 ("-" amread-speed-down :color blue)
471 ("v" amread-voice-reader-toggle :color pink)
472 ("L" amread-voice-reader-switch-language-voice :color pink))
474 ;;;###autoload
475 (define-minor-mode amread-mode
476 "I'm reading mode."
477 :init nil
478 :lighter " amreading"
479 :keymap amread-mode-map
480 (if amread-mode
481 (amread-start)
482 (amread-stop)))
486 (provide 'amread-mode)
488 ;;; amread-mode.el ends here