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") (hydra "0.15.0"))
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].
38 (defcustom amread-word-speed
3.0
39 "Read words per second."
44 (defcustom amread-line-speed
4.0
45 "Read one line using N seconds in average."
50 (defcustom amread-scroll-style nil
51 "Set amread auto scroll style by word or line."
52 :type
'(choice (const :tag
"scroll by word" word
)
53 (const :tag
"scroll by line" line
))
57 (defcustom amread-voice-reader-enabled nil
58 "The initial state of voice reader."
63 (defcustom amread-voice-reader-command
66 (gnu/linux
(or (executable-find "espeak") (executable-find "festival")))
68 "The command for reading text."
73 (defcustom amread-voice-reader-command-options
""
74 "Specify options for voice reader command."
79 (defcustom amread-voice-reader-language
'chinese
80 "Specifiy default language for voice reader."
85 (defface amread-highlight-face
86 '((t :foreground
"black" :background
"ForestGreen"))
87 "Face for amread-mode highlight."
90 (defvar amread--timer nil
)
91 (defvar amread--current-position nil
)
92 (defvar amread--overlay nil
)
94 (defvar amread--voice-reader-proc-finished nil
95 "A process status variable indicate whether voice reader finished reading.
96 It has three status values:
97 - \='not-started :: process not started
98 - \='running :: process still running
99 - \='finished :: process finished")
101 (defmacro amread--voice-reader-status-wrapper
(body)
102 "A wrapper macro for detecting voice reader process status and execute BODY."
103 `(if amread-voice-reader-enabled
104 ;; wait for process finished, then jump to next word.
105 (cl-case amread--voice-reader-proc-finished
109 (t (setq amread--voice-reader-proc-finished
'not-started
)))
113 ;; '(amread--voice-reader-status-wrapper (amread--word-update)))
115 (defun amread--voice-reader-read-text (text)
116 "Read TEXT with voice command-line tool."
117 (when (and amread-voice-reader-enabled
119 (not (string-empty-p text
)))
120 (setq amread--voice-reader-proc-finished
'running
)
123 (or (amread--voice-reader-read-text-with-tts text
)
124 (amread--voice-reader-read-text-with-say text
)))
125 (t (amread--voice-reader-read-text-with-tts text
)))))
127 (defun amread--voice-reader-run-python-code-to-string (&rest python-code-lines
)
128 "Run PYTHON-CODE-LINES through Python interpreter result to string."
129 (let ((python-interpreter (or (executable-find "python3") python-interpreter
)))
130 (shell-command-to-string
134 ;; solve double quote character issue.
135 "\"" (string-replace "\"" "\\\"" (string-join python-code-lines
"\n")) "\"")))))
138 ;; (amread--voice-reader-run-python-code-to-string
139 ;; "import numpy as np"
140 ;; "print(np.arange(6))"
141 ;; "print(\"blah blah\")"
142 ;; "print('{}'.format(3))"))
144 (defun amread--voice-reader-run-python-file-to-string (python-code-file)
145 "Run PYTHON-CODE-FILE through Python interpreter result to string."
146 (let ((python-interpreter (or (executable-find "python3") python-interpreter
)))
147 (shell-command-to-string
148 (format "%s %s" python-interpreter python-code-file
))))
150 (defun amread--voice-reader-run-python-code-in-repl (&rest python-code-lines
)
151 "Run PYTHON-CODE-LINES through Python REPL process result to strings list."
152 (let ((python-interpreter (or (executable-find "python3") python-interpreter
)))
154 ;; `process-send-string' alias `send-string'
155 ;; `python-shell-send-string', `python-shell-internal-send-string', `python-shell-send-string-no-output'
156 (dolist (line python-code-lines
158 ;; assign last eval result to `dolist' binding `result'.
159 (setf result
(python-shell-send-string-no-output line
)))))
161 ;; (amread--voice-reader-run-python-code-in-repl
166 (defvar amread--voice-reader-engine-initialized nil
)
168 ;; invoke cross-platform TTS Speech API through Python library "pyttsx3".
169 (defun amread--voice-reader-read-text-with-tts (text)
170 "Read TEXT with cross-platform TTS Speech API through Python library \"pyttsx3\"."
171 ;; keep instance of engine to avoid repeat creating performance issue.
172 (unless (string-equal amread--voice-reader-engine-initialized
"True")
173 (setq amread--voice-reader-engine-initialized
174 (amread--voice-reader-run-python-code-in-repl
176 "engine = pyttsx3.init()"
178 (amread--voice-reader-run-python-code-in-repl
179 (format "engine.say(\"%s\")" text
)
180 "engine.runAndWait()"))
182 ;; (amread--voice-reader-read-text-with-tts "happy")
184 (defun amread--voice-reader-read-text-with-say (text)
185 "Read TEXT with macOS command `say'."
186 ;; detect language and switch language/voice.
187 (let ((language (amread-voice-reader-switch-language-voice)))
190 (setq amread-voice-reader-command-options
"--voice=Ting-Ting")
191 (message "[amread] voice reader switched to Chinese language/voice."))
193 (setq amread-voice-reader-command-options
"--voice=Ava")
194 (message "[amread] voice reader switched to English language/voice."))
196 (let ((voice (completing-read "[amread] Select language/voice: " '("Ting-Ting" "Ava"))))
197 (setq amread-voice-reader-command-options
(format "--voice=%s" voice
))
198 (message "[amread] voice reader switched to language/voice <%s>." voice
)))))
200 ;; Synchronous Processes
201 ;; (call-process-shell-command
202 ;; amread-voice-reader-command
204 ;; amread-voice-reader-command-options
205 ;; (shell-quote-argument text))
209 :name
"amread-voice-reader"
210 :command
(list amread-voice-reader-command amread-voice-reader-command-options text
)
211 :sentinel
(lambda (_proc event
)
212 (if (string= event
"finished\n")
213 (setq amread--voice-reader-proc-finished
'finished
)))
214 :buffer
" *amread-voice-reader*"
215 :stderr
" *amread-voice-reader*"))
217 (defun amread--word-update ()
218 "Scroll forward by word as step."
219 (let* ((begin (point))
220 ;; move point forward. NOTE This forwarding must be here before moving overlay forward.
221 (_length (+ (skip-chars-forward "^\s\t\n—") (skip-chars-forward "—")))
223 (word (buffer-substring-no-properties begin end
)))
227 (setq amread--current-position nil
))
228 ;; create the overlay if does not exist
229 (unless amread--overlay
230 (setq amread--overlay
(make-overlay begin end
)))
231 ;; move overlay forward
232 (when amread--overlay
233 (move-overlay amread--overlay begin end
))
234 (setq amread--current-position
(point))
235 (overlay-put amread--overlay
'face
'amread-highlight-face
)
237 (amread--voice-reader-read-text word
)
238 (skip-chars-forward "\s\t\n—")
239 ;; when in nov.el ebook, auto navigate to next page.
240 (when (and (eobp) (eq major-mode
'nov-mode
))
242 (message "[amread] nov.el next page.")))))
244 (defun amread--line-update ()
245 "Scroll forward by line as step."
246 (let* ((line-begin (line-beginning-position))
247 (line-end (line-end-position))
248 (line-text (buffer-substring-no-properties line-begin line-end
)))
249 (if (eobp) ; reached end of buffer.
252 (setq amread--current-position nil
))
253 ;; create line overlay to highlight current reading line.
254 (unless amread--overlay
255 (setq amread--overlay
(make-overlay line-begin line-end
)))
257 (when amread--overlay
258 (move-overlay amread--overlay line-begin line-end
))
259 (overlay-put amread--overlay
'face
'amread-highlight-face
)
261 (amread--voice-reader-read-text line-text
)
263 ;; when in nov.el ebook, auto navigate to next page.
264 (when (and (eobp) (eq major-mode
'nov-mode
))
266 (message "[amread] nov.el next page.")))))
268 (defun amread--update ()
269 "Update and scroll forward under Emacs timer."
270 (cl-case amread-scroll-style
272 (amread--voice-reader-status-wrapper (amread--word-update)))
274 (amread--voice-reader-status-wrapper (amread--line-update))
275 ;; Auto modify the running timer REPEAT seconds based on next line words length.
276 (let* ((next-line-words (amread--get-next-line-words)) ; for English
277 ;; TODO: Add Chinese text logic.
278 ;; (next-line-length (amread--get-next-line-length)) ; for Chinese
279 (amread--next-line-pause-secs (truncate (/ next-line-words amread-word-speed
))))
280 (when (> amread--next-line-pause-secs
0)
281 (setf (timer--repeat-delay amread--timer
) amread--next-line-pause-secs
))))
282 (t (user-error "Seems amread-mode is not normally started or not running."))))
284 (defun amread--scroll-style-ask ()
285 "Ask which scroll style to use."
286 (let ((style (intern (completing-read "amread-mode scroll style: " '("word" "line")))))
287 (setq amread-scroll-style style
)
290 (defun amread--get-line-words (&optional pos
)
291 "Get the line words of position."
293 (and pos
(goto-char pos
))
295 (count-words (line-end-position) (line-beginning-position))))
297 (defun amread--get-next-line-words ()
298 "Get the next line words."
299 (amread--get-line-words (save-excursion (forward-line) (point))))
301 (defun amread--get-line-length (&optional pos
)
302 "Get the line length of position."
304 (and pos
(goto-char pos
))
305 (- (line-end-position) (line-beginning-position))))
307 (defun amread--get-next-line-length ()
308 "Get the next line length."
309 (amread--get-line-words (save-excursion (forward-line) (point))))
312 (defun amread-start ()
313 "Start / resume amread."
316 ;; if quit `amread--scroll-style-ask', then don't enable `amread-mode'.
317 (or amread-scroll-style
(amread--scroll-style-ask))
318 (setq amread--voice-reader-proc-finished
'not-started
)
320 (setq amread-voice-reader-language
322 (completing-read "[amread] Select language: " '("chinese" "english"))))
323 ;; select scroll style
324 (if (null amread-scroll-style
)
325 (user-error "User quited entering amread-mode.")
326 ;; resume from paused position
327 (cl-case amread-scroll-style
329 (when amread--current-position
330 (goto-char amread--current-position
))
332 (run-with-timer 0 (/ 1.0 amread-word-speed
) #'amread--update
)))
334 (when amread--current-position
335 (goto-char (point-min))
336 (forward-line amread--current-position
))
338 (run-with-timer 1 amread-line-speed
#'amread--update
)))
339 (t (user-error "Seems amread-mode is not normally started because of not selecting scroll style OR just not running.")))
342 (message "[amread] start reading...")))
345 (defun amread-stop ()
349 (cancel-timer amread--timer
)
350 (setq amread--timer nil
)
351 (when amread--overlay
352 (delete-overlay amread--overlay
)))
353 (setq amread-scroll-style nil
)
355 (hydra-keyboard-quit)
356 (message "[amread] stopped."))
359 (defun amread-pause-or-resume ()
360 "Pause or resume amread."
367 (defun amread-mode-quit ()
368 "Disable `amread-mode'."
371 (hydra-keyboard-quit))
374 (defun amread-speed-up ()
375 "Speed up `amread-mode'."
377 (setq amread-word-speed
(cl-incf amread-word-speed
0.2))
378 (message "[amread] word speed increased -> %s" amread-word-speed
))
381 (defun amread-speed-down ()
382 "Speed down `amread-mode'."
384 (setq amread-word-speed
(cl-decf amread-word-speed
0.2))
385 (message "[amread] word speed decreased -> %s" amread-word-speed
))
388 (defun amread-voice-reader-toggle ()
389 "Toggle text voice reading."
391 (if amread-voice-reader-enabled
393 (setq amread-voice-reader-enabled nil
)
394 (message "[amread] voice reader disabled."))
395 (setq amread-voice-reader-enabled t
)
396 (message "[amread] voice reader enabled.")))
398 (defun amread--voice-reader-detect-language (&optional string
)
399 "Detect text language."
400 ;; Return t if STRING is a Chinese string.
401 (if-let ((string (or string
(word-at-point))))
403 ;; `pyim-probe-auto-english'
404 ((null (pyim-probe-dynamic-english))
406 ((pyim-probe-dynamic-english)
408 ((string-match (format "\\cC\\{%s\\}" (length string
)) string
)
412 ;; (amread--voice-reader-detect-language "测试")
413 ;; (amread--voice-reader-detect-language "测试test")
416 (defun amread-voice-reader-switch-language-voice (&optional language
)
417 "Switch voice reader LANGUAGE or voice."
419 (let ((language (or language
420 (when (called-interactively-p 'interactive
)
421 (intern (completing-read "[amread] Select language: " '("chinese" "english"))))
422 amread-voice-reader-language
423 (amread--voice-reader-detect-language))))
427 (defun amread-voice-reader-read-buffer ()
428 "Read current buffer text without timer highlight updating."
430 ;; loop over all lines of buffer.
434 (goto-char (point-min))
436 (let* ((line-begin (line-beginning-position))
437 (line-end (line-end-position))
438 (line-text (buffer-substring-no-properties line-begin line-end
)))
440 (let ((amread-voice-reader-enabled t
))
441 (amread--voice-reader-status-wrapper
442 (amread--voice-reader-read-text line-text
)))
443 (forward-line 1))))))
445 (defvar amread-mode-map
446 (let ((map (make-sparse-keymap)))
447 (define-key map
(kbd "q") #'amread-mode-quit
)
448 (define-key map
(kbd "SPC") #'amread-pause-or-resume
)
449 (define-key map
[remap keyboard-quit
] #'amread-mode-quit
)
450 (define-key map
(kbd "+") #'amread-speed-up
)
451 (define-key map
(kbd "-") #'amread-speed-down
)
452 (define-key map
(kbd "v") #'amread-voice-reader-toggle
)
453 (define-key map
(kbd "L") #'amread-voice-reader-switch-language-voice
)
454 (define-key map
(kbd ".") #'amread-hydra
/body
)
456 "Keymap for `amread-mode' buffers.")
459 (defhydra amread-hydra
(:color green
:hint nil
:exit nil
)
461 ^Control^ ^Adjust When Reading^
462 ^------------------^ ^-------------------------^
463 _SPC_: pause/resume _+_: speed up
464 _q_: quit _-_: speed down
465 ^ ^ _v_: toggle voice reader
466 ^ ^ _L_: switch language/voice
468 ("SPC" amread-pause-or-resume
:color blue
)
469 ("q" amread-mode-quit
:color red
)
470 ("+" amread-speed-up
:color blue
)
471 ("-" amread-speed-down
:color blue
)
472 ("v" amread-voice-reader-toggle
:color pink
)
473 ("L" amread-voice-reader-switch-language-voice
:color pink
))
476 (define-minor-mode amread-mode
480 :keymap amread-mode-map
487 (provide 'amread-mode
)
489 ;;; amread-mode.el ends here