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
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)
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/>.
29 ;;; 1. Launch amread-mode with command `amread-mode'.
30 ;;; 2. Stop amread-mode by pressing [q].
37 (defcustom amread-word-speed
3.0
38 "Read words per second."
43 (defcustom amread-line-speed
4.0
44 "Read one line using N seconds in average."
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
))
56 (defcustom amread-voice-reader-enabled nil
57 "The initial state of voice reader."
62 (defcustom amread-voice-reader-command
65 (gnu/linux
(or (executable-find "espeak") (executable-find "festival")))
67 "The command for reading text."
72 (defcustom amread-voice-reader-command-options
""
73 "Specify options for voice reader command."
78 (defcustom amread-voice-reader-language
'chinese
79 "Specifiy default language for voice reader."
84 (defface amread-highlight-face
85 '((t :foreground
"black" :background
"ForestGreen"))
86 "Face for amread-mode highlight."
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
108 (t (setq amread--voice-reader-proc-finished
'not-started
)))
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
118 (not (string-empty-p text
)))
119 (setq amread--voice-reader-proc-finished
'running
)
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
133 ;; solve double quote character issue.
134 "\"" (string-replace "\"" "\\\"" (string-join python-code-lines
"\n")) "\"")))))
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
)))
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
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
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
175 "engine = pyttsx3.init()"
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)))
189 (setq amread-voice-reader-command-options
"--voice=Ting-Ting")
190 (message "[amread] voice reader switched to Chinese language/voice."))
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
203 ;; amread-voice-reader-command-options
204 ;; (shell-quote-argument text))
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 "—")))
222 (word (buffer-substring-no-properties begin end
)))
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
)
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
))
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.
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
)))
256 (when amread--overlay
257 (move-overlay amread--overlay line-begin line-end
))
258 (overlay-put amread--overlay
'face
'amread-highlight-face
)
260 (amread--voice-reader-read-text line-text
)
262 ;; when in nov.el ebook, auto navigate to next page.
263 (when (and (eobp) (eq major-mode
'nov-mode
))
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
271 (amread--voice-reader-status-wrapper (amread--word-update)))
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
)
289 (defun amread--get-line-words (&optional pos
)
290 "Get the line words of position."
292 (and pos
(goto-char pos
))
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."
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))))
311 (defun amread-start ()
312 "Start / resume amread."
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
)
319 (setq amread-voice-reader-language
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
328 (when amread--current-position
329 (goto-char amread--current-position
))
331 (run-with-timer 0 (/ 1.0 amread-word-speed
) #'amread--update
)))
333 (when amread--current-position
334 (goto-char (point-min))
335 (forward-line amread--current-position
))
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.")))
341 (message "[amread] start reading...")))
344 (defun amread-stop ()
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
)
354 (hydra-keyboard-quit)
355 (message "[amread] stopped."))
358 (defun amread-pause-or-resume ()
359 "Pause or resume amread."
366 (defun amread-mode-quit ()
367 "Disable `amread-mode'."
370 (hydra-keyboard-quit))
373 (defun amread-speed-up ()
374 "Speed up `amread-mode'."
376 (setq amread-word-speed
(cl-incf amread-word-speed
0.2))
377 (message "[amread] word speed increased -> %s" amread-word-speed
))
380 (defun amread-speed-down ()
381 "Speed down `amread-mode'."
383 (setq amread-word-speed
(cl-decf amread-word-speed
0.2))
384 (message "[amread] word speed decreased -> %s" amread-word-speed
))
387 (defun amread-voice-reader-toggle ()
388 "Toggle text voice reading."
390 (if amread-voice-reader-enabled
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))))
402 ;; `pyim-probe-auto-english'
403 ((null (pyim-probe-dynamic-english))
405 ((pyim-probe-dynamic-english)
407 ((string-match (format "\\cC\\{%s\\}" (length string
)) string
)
411 ;; (amread--voice-reader-detect-language "测试")
412 ;; (amread--voice-reader-detect-language "测试test")
415 (defun amread-voice-reader-switch-language-voice (&optional language
)
416 "Switch voice reader LANGUAGE or voice."
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))))
426 (defun amread-voice-reader-read-buffer ()
427 "Read current buffer text without timer highlight updating."
429 ;; loop over all lines of buffer.
433 (goto-char (point-min))
435 (let* ((line-begin (line-beginning-position))
436 (line-end (line-end-position))
437 (line-text (buffer-substring-no-properties line-begin line-end
)))
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
)
455 "Keymap for `amread-mode' buffers.")
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
))
475 (define-minor-mode amread-mode
478 :lighter
" amreading"
479 :keymap amread-mode-map
486 (provide 'amread-mode
)
488 ;;; amread-mode.el ends here