Repository navigation
Expand file tree
/
Copy pathSpeedReader.ahk
More file actions
3128 lines (2929 loc) · 149 KB
/
Copy pathSpeedReader.ahk
File metadata and controls
3128 lines (2929 loc) · 149 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
; ======================================================================================================================
; SpeedReader.ahk — A speed reading trainer for plain-text files
; Author: Steve (kunkel321) with Claude (Anthropic)
; Version Date: 7-7-2026
; Requires: AutoHotkey v2.0+ | RichEdit.ahk by just-me (place in Tools\ folder)
; https://github.com/AHK-just-me/AHK2_RichEdit
; ======================================================================================================================
;
; OVERVIEW
; --------
; SpeedReader opens a .txt file and highlights a configurable "chunk" of words at a time,
; advancing through the text at a user-set WPM rate. Pauses are automatically inserted at
; sentence, paragraph, and list-item boundaries. The goal is to train faster reading by
; giving the eye a single focal point that moves at a controlled pace. Works well with
; plain-text books from Project Gutenberg and similar sources.
;
; THANKS
; ------
; Special thanks to rommmeck, for the feedback, testing, and critical recommendations.
;
; SETUP
; -----
; Portable mode (recommended): rename AutoHotkey64.exe to SpeedReader.exe and place it
; in the same folder as SpeedReader.ahk and RichEdit.ahk. No installation needed.
; Standard mode: run SpeedReader.ahk directly with AHK v2 installed on the machine.
; Either way, RichEdit.ahk must be in the same folder as SpeedReader.ahk.
; 1. On first run, srSettings.ini is created automatically with sensible defaults.
; 2. Open a .txt file via File > Open, by dragging a file onto the window, or by
; choosing from the recent-files list in the File menu.
; 3. Press Play (or the Down arrow key) to begin. Press again to pause.
;
; CONTROLS
; --------
; Play / Pause ▶ Play button —or— Down arrow key (window must be active)
; Restart ⟲ Restart button — clears highlight and rewinds to word 1
; Speed (WPM) Big slider at the bottom of the window, plus:
; Left / Right arrow keys (±WPMHotkeyStep per press, default ±25)
; Mouse wheel over the slider (wheel-up = faster, wheel-down = slower)
; Jump to any word Double-click the word — playback restarts from that point immediately.
; Works whether the reader is playing, paused, or stopped.
; Open file File > Open (Ctrl+O), or drag-and-drop a .txt file onto the window
; Focus search box Ctrl+F (pauses the pacer if running; selects existing text)
; Recent files Numbered list in the File menu (files that no longer exist are omitted)
; Resume position When a file is re-opened that has a saved position, a prompt offers
; to resume from the last reading point or start over.
; Position is saved automatically on pause and on window close.
;
; TOP TOOLBAR (never moves during window resize)
; -----------
; ▶ Play / ❚❚ Pause Toggle playback
; ⟲ Restart Rewind to the beginning
; Size Font size spin-box — live update, no restart needed
; Chunk Words highlighted per tick (1–10). Larger = easier at high WPM.
; Status bar Filename + word count while stopped; "Word X of Y (Z%)" while playing
;
; ROW 2 (WPM display + checkboxes)
; ------
; WPM: N [m:ss] Large WPM readout. Once ~10 words have been read a rolling
; time-remaining estimate appears in [m:ss] format, updated at the
; interval set by TimeRemainingUpdateMs (default once per second).
; The estimate uses actual dwell times, so it accounts for sentence
; pauses, smart pacing, chunk size, etc. automatically.
;
; Overlap Sliding-window mode: the chunk-sized highlight advances one word per
; tick instead of jumping by a whole chunk. Gives a smoother,
; flowing feel. Sentence/list pause multipliers are suppressed in
; this mode (they would disrupt the slide); Smart pacing still works.
;
; Sentence pause Dwell longer at sentence ends (. ! ? …) and strong mid-sentence
; breaks (; : em-dash). Paragraph ends always pause regardless.
; Comma pauses are lighter and never clip a chunk boundary.
; Common abbreviations (Mr. Dr. etc. e.g. p. …) are excluded from
; sentence detection so they don't trigger false pauses.
;
; List pauses Detect short lines that look like list or enumeration items and
; pause at the end of each. A line qualifies if it is short
; (≤ ListMaxWords words), doesn't end in a comma/semicolon/hyphen,
; doesn't end in a bare conjunction (and/or/but…), and the next
; line starts with a capital letter.
;
; Smart pacing Scale each word's dwell time by its reading difficulty:
; Stopwords (the, of, and …) → StopwordWeight × base (faster)
; Monosyllabic content words → MonoWeight × base (baseline)
; Polysyllabic words → +ExtraSyllableWeight per extra
; syllable beyond the first (slower)
; Syllables are counted with a vowel-group heuristic; silent trailing
; 'e' is subtracted. Good enough for relative weighting purposes.
;
; Center scroll Keep the highlighted word vertically centered in the reading pane
; as the text advances. When off, the pane scrolls only when the
; highlight reaches the bottom edge (standard behavior).
; Scrolling is in whole-line increments (RichEdit limitation).
;
; Read Aloud Speaks the text using Windows' built-in SAPI5 text-to-speech.
; SAPI is queued one sentence at a time; within a sentence the
; highlight follows SAPI's own word-boundary events, so the voice
; and the highlight stay in genuine sync. If a voice doesn't
; report word boundaries, an internal fallback pacer (the same
; WPM/weighted dwell-time math the silent pacer uses) carries the
; highlight instead, and each sentence's end resyncs to the last
; word once SAPI actually finishes speaking it. If a voice's
; word events fire but track poorly, turn on Settings > "Force
; fallback pacer (Read Aloud)" (or set ForceFallbackPacer=1 under
; [Reader] in srSettings.ini) to ignore them and use the fallback
; pacer unconditionally; it applies live on the next spoken word.
; Read Aloud is only available
; at or below TTSMaxWPM (see the tunables near the top of the
; script) since spoken word stops being intelligible well before
; the top of the WPM slider's range; the checkbox disables itself
; automatically above that speed, and re-enables itself when WPM
; drops back down. Chunk / Overlap / Sentence pause / List pauses /
; Smart pacing are all timer-engine concepts and are grayed out
; while Read Aloud is active. Play/Pause and double-click-to-jump
; work the same as always. The Voice dropdown next to the checkbox
; lists every SAPI voice installed on this machine; "(Default
; voice)" leaves whatever Windows considers the default in place.
; Changing the voice takes effect on the next launch, not
; immediately — restart SpeedReader after picking a new one.
;
; COLOR / FONT / SEARCH ROW (bottom band, Row 1)
; -------------------------
; Highlight / Text / Background
; Clickable colored buttons — click to open the Windows color picker.
; Changes apply immediately to live text.
; Font Drop-down of preset font faces (condensed).
; Search Incremental search box — results highlighted as you type (debounced).
; Literal by default — "first second" matches the exact phrase,
; including punctuation ("Mr. Darcy" matches literally). Matching
; is case-insensitive and can span multiple words.
; Regex mode: prefix the term with '/' (optional trailing '/'):
; /first\s+second/ — first + any whitespace + second
; /\bthe\s+\w+ing\b/ — 'the' followed by an -ing word
; /^Chapter \d+/m — multi-line anchors work
; Invalid regex patterns show a brief error in the status bar.
; Ctrl+F focuses the search box (pauses the pacer if running).
; F3 advances to the next match (honors current mode).
; Disabled while the pacer is running; re-enabled on pause.
; Clearing the box removes the search highlight immediately.
;
; MENUS
; -----
; File > Open Standard open dialog, filtered to .txt files (Ctrl+O)
; File > Convert PDF/EPUB/TXT… Launches TextExtractor.ahk companion tool
; File > Recently converted Submenu of *.txt files in the Converted\ subfolder (newest first)
; File > 1 … N Recent files, most-recent-first (missing files skipped)
; File > Exit
; Settings > Force fallback pacer (Read Aloud) Toggles ForceFallbackPacer (checkmark);
; takes effect live on the next spoken word.
; Settings > Break tint at colons & dashes Toggles ClauseTintBreaks (checkmark);
; re-segments the Sentence-tint bands live, no reload.
; Settings > Open srSettings.ini Opens the INI in your default text editor
; Links > Project Gutenberg https://www.gutenberg.org/ebooks/results/
; Links > AutoHotkey forum Forum thread for this script (set URL_AhkForum below)
; Links > GitHub repo Repository for this script (set URL_GitHub below)
; Debug > Token analysis ListView of tokenization flags for ~80 words around the
; current reading position. Columns: index, text,
; endsSentence, endsParagraph, endsLine, endsListItem,
; endsCommaLike, weight. Ctrl+C copies all rows as CSV.
;
; TEXTEXTRACTOR INTEGRATION
; -------------------------
; SpeedReader polls srSettings.ini every 1.5 s for a new TEConvertedAt timestamp written
; by TextExtractor after each successful conversion. When a new stamp is detected the
; converted file is loaded silently; the status bar shows the filename.
; The polling only fires when the SpeedReader window is active, so there are no stale
; loads while the user is working in TextExtractor.
;
; SETTINGS FILE (srSettings.ini, auto-created next to the script on first run)
; -------------
; All GUI settings save automatically on change and restore on next launch.
; [Reader] WPM, ChunkSize, Overlap, SentencePause, ListPauses, SmartPacing, CenterScroll
; [Colors] HighlightColor, TextColor, BackColor (stored as decimal RGB integers)
; [Font] Name, Size
; [srWindow] W, H (saved on close)
; [Session] LastFile, TEConvertedAt
; LastFile is stored as "path" or "path, wordIdx" and auto-loaded on launch.
; TEConvertedAt is the sentinel timestamp written by TextExtractor.
; [Recent] File1 … FileN — each value is "path" or "path, wordIdx".
; The word index is the saved reading position for that file.
; Missing files are silently skipped on load.
;
; DEVELOPER TUNABLES (near the top of this file, above the Cfg block)
; ------------------
; Pacing object Dwell-time multipliers applied on top of the base WPM rate:
; SentenceMult after .!?… (default 1.8×)
; ParagraphMult after blank line (default 2.5×)
; CommaMult after comma (default 1.15×)
; SemicolonColonMult after ; : em-dash (default 1.5×)
; ListItemMult after list item (default 1.4×)
; StopwordWeight fast-word weight (default 0.5)
; MonoWeight baseline weight (default 1.0)
; ExtraSyllableWeight per extra syllable (default 0.3)
; WPMHotkeyStep Arrow-key WPM step size (default 25)
; ListMaxWords Max line length in words for list-item detection (default 12)
; TimeRemainingUpdateMs How often [m:ss] refreshes, in ms (default 1000 = once/second)
; RecentFilesMax How many recent files to remember in the File menu (default 9)
; SearchFromCurrent true → search starts from current reading position (default)
; false → search always starts from top of document
; SearchDebounceMs Delay (ms) after last keystroke before search fires (default 300)
; URL_AhkForum AHK forum thread URL — fill in when known
; URL_GitHub GitHub repo URL — fill in when known
;
; TECHNICAL NOTES
; ---------------
; • Uses Just Me's RichEdit.ahk wrapper (RICHEDIT50W from Msftedit.dll, RichEdit v4.1).
; • All character offsets are zero-based UTF-16 code units, matching RichEdit's internal
; indexing. AHK v2 strings are also UTF-16, so StrLen() and offsets always agree.
; • Line endings are normalized to bare LF on load because RichEdit stores CR-only
; internally. Feeding CRLF without stripping \r causes one-character offset drift per
; line — the highlight gets progressively misaligned through the document.
; • The blinking caret is permanently suppressed by hooking WM_SETFOCUS and calling
; CreateCaret(hwnd, 0, 0, 0) + HideCaret each time RichEdit gains focus, plus after
; every programmatic SetSel call. A zero-width caret is invisible regardless of blink.
; • Drag-and-drop uses DragAcceptFiles + WM_DROPFILES. Only the first dropped file is
; used; non-.txt files are rejected with a message box.
; • Mouse-wheel direction on the WPM slider is flipped (wheel-up = increase WPM) via a
; WM_MOUSEWHEEL hook that only intercepts when the cursor is over the slider hwnd.
; • Center-scroll uses EM_LINEFROMCHAR, EM_GETFIRSTVISIBLELINE, and EM_LINESCROLL.
; Line height is measured from two currently-visible lines (not line 0/1) so the
; measurement stays valid after the view has scrolled away from the top.
; ======================================================================================================================
#Requires AutoHotkey v2.0
#SingleInstance Force
#Include "Tools\RichEdit.ahk"
SetWinDelay -1
SetControlDelay -1
; ======================================================================================================================
; Pacing tunables — adjust these to change the "feel" of pauses and emphasis.
; Each is a multiplier on the baseline per-chunk dwell time (60000 * chunk / WPM).
; ======================================================================================================================
global Pacing := {
SentenceMult: 2.3, ; dwell ×1.8 on chunks ending in .!?… (breathing room after a sentence)
ParagraphMult: 2.0, ; dwell ×2.5 on chunks ending a paragraph (blank line follows)
CommaMult: 1.5, ; dwell ×1.15 on chunks ending in a comma
SemicolonColonMult: 1.9, ; dwell ×1.30 on chunks ending in ; : or em-dash
ListItemMult: 1.40, ; dwell ×1.40 on chunks ending a detected list item
; Intelligent-pacing word weights (applied only when Cfg.SmartPacing is on):
StopwordWeight: 0.3, ; 'the', 'of', 'and', ... — read fast
MonoWeight: 1.0, ; 1-syllable non-stopword baseline
ExtraSyllableWeight: 0.6 ; added per syllable beyond the first for polysyllabic words
}
; WPM hotkey step (Left/Right arrows adjust WPM by this amount)
global WPMHotkeyStep := 80
; List-detection heuristic thresholds (for the "List pauses" feature)
global ListMaxWords := 12 ; line must be shorter than this to be a candidate list item
; How often (ms) the time-remaining display refreshes. 1000 = once per second.
global TimeRemainingUpdateMs := 1000
; Maximum number of recent files to remember in the File menu (1–16)
global RecentFilesMax := 9
; Search behaviour
; SearchFromCurrent = true → first search starts from word at/after CurIdx (context-aware)
; SearchFromCurrent = false → first search always starts from word 1 (top of document)
global SearchFromCurrent := true
; Debounce delay (ms) after the last keystroke before the search fires
global SearchDebounceMs := 300
; ---- Read Aloud (TTS) tunables ----------------------------------------------------------
; Above this WPM, "Read Aloud" is disabled (spoken word is unintelligible much past this,
; and it stops being useful as a reading aid at high speeds anyway).
global TTSMaxWPM := 500
; Approximate calibration for mapping the WPM slider to SAPI's -10..10 Rate scale.
; SAPI's Rate isn't WPM-calibrated; this assumes rate 0 ≈ AvgWpmAtRate0 for the default
; voice and that each rate step changes speed by roughly PctPerStep. Adjust to taste.
global TTSAvgWpmAtRate0 := 180
global TTSPctPerStep := 0.10
; SVSFlagsAsync = 1, SVSFPurgeBeforeSpeak = 2, SVSFIsNotXML = 16 — SAPI's own constants,
; inlined since AHK has no COM enum lookup for them. Declared up here (not near the TTS
; functions below) because they must execute before the "end of auto-execute section"
; Return. SVSFIsNotXML matters for highlighting: without it SAPI parses the input as
; potential XML, and characters like & or < can shift the CharacterPosition offsets its
; Word events report, throwing the highlight off for the rest of that chunk.
global SVSFlagsAsync := 1
global SVSFPurgeBeforeSpeak := 2
global SVSFIsNotXML := 16
; SAPI's SpeechVoiceEvents bitmask — a freshly-created SAPI.SpVoice does NOT notify
; word-boundary events by default; EventInterests has to be set explicitly to opt in.
; SVEStartInputStream=2, SVEEndInputStream=4, SVEWordBoundary=32, SVESentenceBoundary=128.
global TTSEventInterests := 2 | 4 | 32 | 128
global DebugLog := False
global DebugLogFile := A_ScriptDir "\SpeedReader_debug.log"
; Tray icon — An airplane image.
TraySetIcon("imageres.dll", VerCompare(A_OSVersion, "10.0.22000") >= 0 ? 329 : 330)
; ======================================================================================================================
; Global configuration
; ======================================================================================================================
global AppName := "SpeedReader"
global IniFile := A_ScriptDir "\srSettings.ini"
; ---- External URLs — fill these in when known -----------------------------------------------------------------------
global URL_Gutenberg := "https://www.gutenberg.org/ebooks/results/"
global URL_AhkForum := "https://www.autohotkey.com/boards/viewtopic.php?f=83&t=140586"
global URL_GitHub := "https://github.com/kunkel321/SpeedReader"
; Defaults — overridden by srSettings.ini if present
global Cfg := {
WPM: 525,
ChunkSize: 1,
Overlap: False,
SentencePause: True, ; longer dwell at sentence / paragraph boundaries
ListPauses: False, ; detect line-oriented lists and pause at each item
SmartPacing: True, ; weight dwell by word difficulty (stopword/syllables)
CenterScroll: True, ; keep highlighted word vertically centered in the control
TTSMode: False, ; Read Aloud — SAPI speaks and drives the highlight pacing
TTSVoiceId: "", ; SAPI voice token Id; "" = leave SAPI's own default voice
ForceFallbackPacer: False, ; Escape hatch (Settings menu, or ForceFallbackPacer=1
; under [Reader] in srSettings.ini): ignore SAPI's
; Word-boundary events and pace the Read Aloud highlight
; with the internal dwell-time fallback instead. For voices
; (anticipated mainly on Win 11) whose Word events fire but
; misreport positions — auto-detection can't catch "present
; but wrong." Read live per word event, so it applies at once.
HighlightColor: 16777088, ; 0xFFFF80 — pale yellow
TextColor: 1913944, ; 0x1D3458 — dark blue
BackColor: 15724527, ; 0xEFEFEF — light gray
SentenceColor: 15131329, ; 0xE6E2C1 — grayish yellow (current-sentence tint)
ColorTint: False, ; tint the whole current sentence behind the chunk highlight
ClauseTintBreaks: False, ; Settings menu (or ClauseTintBreaks=1 under [Reader] in
; srSettings.ini): ALSO end a Sentence-tint band at a colon,
; an em-dash, or a plain-text "--". Off by default: it
; fragments long sentences (reducing the "whole sentence as
; context" value) and can split dash/colon phrases and
; titles. Toggling re-segments the bands live (no reload).
FontName: "Verdana",
FontSize: 14,
GuiW: 900,
GuiH: 650,
LastFile: "" ; stored as "path, wordIdx" in the INI
}
; These two must be declared before LoadSettings() runs, because LoadSettings populates them.
global RecentFiles := [] ; ordered list of recently opened file paths (most recent first)
global RecentPositions := Map() ; lowercase-path → saved word index (loaded from INI CSV)
LoadSettings()
; ======================================================================================================================
; Runtime state
; ======================================================================================================================
global Words := [] ; array of {start, end} zero-based RichEdit offsets
global FullText := "" ; normalized document text — drives phrase/regex search (keep in sync with RE content)
global CurIdx := 0 ; 0 = not started; otherwise index of last-highlighted word
global PrevStart := -1 ; start offset of previously highlighted chunk (for un-highlighting)
global PrevEnd := -1
global PrevSentStart := -1 ; start offset of previously tinted sentence (Color-tint feature; -1 = none)
global PrevSentEnd := -1
global IsPlaying := False
global StepTimer := StepWord.Bind() ; bound timer callback
; Read Aloud (TTS) engine state — see StartTTSEngine/StopTTSEngine and the SAPI_* event
; handlers near the bottom of the playback section.
global TTSEngine := "" ; SAPI.SpVoice COM object, created lazily on first use
global TTSSpeaking := False ; True once a sentence-chunk Speak() is queued/active
; (stays True across a soft Pause(), so Resume() can pick back up)
global TTSBaseOffset := 0 ; FullText char offset where the current sentence-chunk began
global TTSNextWordIdx := 0 ; index of the next word to queue once the current chunk ends
global TTSChunkEndIdx := 0 ; last word index of the chunk currently being spoken
global TTSStreams := Map() ; StreamNumber -> {baseOff, endIdx} for every utterance WE queued.
; SAPI_Word/SAPI_EndStream look their StreamNumber up here:
; unknown stream = a stale echo (from a purge, or the empty
; purge-utterance itself) and is ignored exactly, with no
; echo-counting guesswork; known stream = highlight against
; THAT stream's own base offset, so even if the queue ever
; held two chunks, events could not map onto the wrong chunk.
; Cleared on every hard stop (all in-flight streams become
; stale by definition). This replaced a one-shot
; TTSIgnoreNextEndStream flag, which absorbed only a single
; purge echo: a second echo arriving after new playback had
; started was mistaken for a natural chunk completion,
; double-queuing chunks and leaving the highlight a full
; sentence ahead of the voice.
global TTSSubTimer := TTSSubStep.Bind() ; FALLBACK word-by-word pacer within a chunk —
; permanently benched once TTSWordEventsSeen latches
global TTSWordEventsSeen := False ; latches True the first time SAPI_Word fires for this
; engine. Once True, the fallback sub-pacer is never
; armed again — real Word events own the highlight
; outright, with no timer racing them (a race here was
; the cause of the highlight's momentary jump-ahead/
; snap-back glitch; diagnosis credit: rommmcek).
; Reset whenever the engine is (re)created, since word-
; event support is a property of the voice in use.
global TTSVoiceList := [] ; installed SAPI voices: array of {Id, Name, Token}
; Rolling dwell buffer for time-remaining estimate.
; Stores ms-per-word samples (dwell / words_in_chunk) for the last N ticks.
global DwellBuf := [] ; circular buffer of ms-per-word samples
global DwellBufMax := 50 ; how many samples to keep
global DwellBufSum := 0.0 ; running sum for O(1) average
global DwellMinSamples := 10 ; don't show estimate until we have this many samples
; Search state
global SrchMatchStart := -1 ; char offset of current search highlight start (-1 = none)
global SrchMatchEnd := -1 ; char offset of current search highlight end
global SrchLastWord := "" ; the term that produced the current highlight
; Enumerate installed SAPI voices up front (a throwaway SpVoice, not the lazily-created
; TTSEngine used for actual playback) so the Voice dropdown has choices before the first
; Read Aloud session. Failure here just leaves TTSVoiceList empty — Read Aloud still
; works with SAPI's own default voice, there's just nothing to pick from in the dropdown.
EnumerateTTSVoices() {
global TTSVoiceList
TTSVoiceList := []
Try {
tmp := ComObject("SAPI.SpVoice")
voices := tmp.GetVoices()
Loop voices.Count {
tok := voices.Item(A_Index - 1) ; SAPI collections are zero-based
name := "<unnamed voice>"
Try name := tok.GetDescription()
TTSVoiceList.Push({Id: tok.Id, Name: name, Token: tok})
}
DBG("EnumerateTTSVoices — found " TTSVoiceList.Length " voice(s)")
} Catch as e {
DBG("EnumerateTTSVoices — failed: " e.Message)
}
}
EnumerateTTSVoices()
; ======================================================================================================================
; Build GUI
; ======================================================================================================================
MainGui := Gui("+Resize +MinSize600x400", AppName)
MainGui.OnEvent("Size", MainGuiSize)
MainGui.OnEvent("Close", GuiClosing)
MainGui.MarginX := 8
MainGui.MarginY := 8
; --- Menu bar ---------------------------------------------------------------------------------------------------------
; NOTE: FileMenu is rebuilt by RebuildRecentMenu() each time a file is opened.
; The static items (Open, Convert, separator) are always added first, then the recently
; converted submenu (if any), then recent files, then Exit.
global FileMenu := Menu()
FileMenu.Add("&Open...`tCtrl+O", FileOpen)
FileMenu.Add("Con&vert PDF/EPUB/TXT...", LaunchTextExtractor)
; Recently converted submenu, recent files, and Exit are appended by RebuildRecentMenu().
; TE output watcher — tracks the last TEConvertedAt stamp we acted on so we don't
; load the same conversion twice.
global TELastStampSeen := ""
; --- Settings menu — exposes the two advanced toggles that used to be INI-only, plus the
; INI file itself. Item labels are held in globals so the check/uncheck calls in the
; handlers match the exact strings passed to Add() (including & accelerators).
global MenuLbl_FallbackPacer := "&Force fallback pacer (Read Aloud)"
global MenuLbl_ClauseBreaks := "&Break tint at colons && dashes"
global SettingsMenu := Menu()
SettingsMenu.Add(MenuLbl_FallbackPacer, ToggleForceFallbackPacer)
SettingsMenu.Add(MenuLbl_ClauseBreaks, ToggleClauseTintBreaks)
If (Cfg.ForceFallbackPacer)
SettingsMenu.Check(MenuLbl_FallbackPacer)
If (Cfg.ClauseTintBreaks)
SettingsMenu.Check(MenuLbl_ClauseBreaks)
SettingsMenu.Add() ; separator
SettingsMenu.Add("Open &srSettings.ini", OpenIniFile)
LinksMenu := Menu()
LinksMenu.Add("&Project Gutenberg", (*) => Run(URL_Gutenberg))
LinksMenu.Add("&AutoHotkey forum thread", (*) => Run(URL_AhkForum))
LinksMenu.Add("&GitHub repo", (*) => Run(URL_GitHub))
DebugMenu := Menu()
DebugMenu.Add("&Token analysis...", ShowTokenAnalysis)
MenuBarObj := MenuBar()
MenuBarObj.Add("&File", FileMenu)
MenuBarObj.Add("&Settings", SettingsMenu)
MenuBarObj.Add("&Links", LinksMenu)
MenuBarObj.Add("&Debug", DebugMenu)
MainGui.MenuBar := MenuBarObj
; --- Top toolbar row --------------------------------------------------------------------------------------------------
BtnPlay := MainGui.AddButton("xm ym w90 h28", "▶ Play")
BtnPlay.OnEvent("Click", TogglePlay)
BtnRestart := MainGui.AddButton("x+4 yp wp hp", "⟲ Restart")
BtnRestart.OnEvent("Click", Restart)
; Size spinner and the Font dropdown live in the top toolbar (not the bottom band) so the
; Size spin-buttons stay glued to their edit and never move on resize — the toolbar is
; anchored at xm ym. (Chunk moved down to Row 2 with the other reading-behavior controls.)
LblSize := MainGui.AddText("x+16 yp+6 w30", "Size:")
EdSize := MainGui.AddEdit("x+2 yp-4 w50 Number", Cfg.FontSize)
UdSize := MainGui.AddUpDown("Range6-72", Cfg.FontSize)
EdSize.OnEvent("Change", FontChanged)
LblFont := MainGui.AddText("x+16 yp+4 w32", "Font:")
DdlFont := MainGui.AddDropDownList("x+2 yp-4 w110", ["Georgia","Arial","Verdana","Tahoma","Calibri","Consolas","Courier New","Times New Roman"])
TryPickDDL(DdlFont, Cfg.FontName)
DdlFont.OnEvent("Change", FontChanged)
; Status label stays on the top row, after the font dropdown
LblStatus := MainGui.AddText("x+20 yp+2 w400 h20", "No file loaded.")
LblStatus.SetFont("s13")
; --- RichEdit (middle, resizes) ---------------------------------------------------------------------------------------
global RE := RichEdit(MainGui, "xm y+8 w" (Cfg.GuiW - 16) " h" (Cfg.GuiH - 220))
RE.SetOptions(["READONLY"], "OR")
RE.WordWrap(True)
ApplyFontSettings()
ApplyBackColor()
; --- Bottom band: three rows (final positions set by MainGuiSize) -----------------------------------------------------
RE.GetPos(&reX, &reY, &reW, &reH)
global Row1Y := reY + reH + 6 ; colors + Sentence-tint + search
global Row2Y := Row1Y + 34 ; Chunk + checkboxes + voice
global Row3Y := Row2Y + 30 ; WPM/time text + slider
; Color swatches — Progress bars at 100% (c = bar color, Smooth = solid fill), in the
; order Text, Background, Highlight, Sentence. Labels are BackgroundTrans Text controls
; superimposed at the same coords (SS_CENTER=0x01, SS_CENTERIMAGE=0x200, transparent).
global SwTx := MainGui.AddProgress("xm y" Row1Y " w60 h24 Smooth c" Fmt(Cfg.TextColor), 100)
global SwBg := MainGui.AddProgress("x+4 yp w90 h24 Smooth c" Fmt(Cfg.BackColor), 100)
global SwHL := MainGui.AddProgress("x+4 yp w80 h24 Smooth c" Fmt(Cfg.HighlightColor), 100)
global SwSn := MainGui.AddProgress("x+4 yp w80 h24 Smooth c" Fmt(Cfg.SentenceColor), 100)
SwTx.GetPos(&stX, &stY, &stW, &stH)
SwBg.GetPos(&sbX, &sbY, &sbW, &sbH)
SwHL.GetPos(&shX, &shY, &shW, &shH)
SwSn.GetPos(&ssX, &ssY, &ssW, &ssH)
global LblSwTx := MainGui.AddText("x" stX " y" stY " w" stW " h" stH " +0x01 +0x200 BackgroundTrans", "Text")
global LblSwBg := MainGui.AddText("x" sbX " y" sbY " w" sbW " h" sbH " +0x01 +0x200 BackgroundTrans", "Background")
global LblSwHL := MainGui.AddText("x" shX " y" shY " w" shW " h" shH " +0x01 +0x200 BackgroundTrans", "Highlight")
global LblSwSn := MainGui.AddText("x" ssX " y" ssY " w" ssW " h" ssH " +0x01 +0x200 BackgroundTrans", "Sentence")
; Sentence-tint checkbox sits right after the Sentence swatch.
CbxColorTint := MainGui.AddCheckbox("x+12 yp+3", "Sentence tint")
CbxColorTint.Value := Cfg.ColorTint
CbxColorTint.OnEvent("Click", ColorTintChanged)
; Search box — fills the remaining width of Row 1.
; Disabled while the pacer is running (enabled in StopPlay, disabled in StartPlay).
LblSearch := MainGui.AddText("x+14 yp+2 w46", "Search:")
global SrchBox := MainGui.AddEdit("x+2 yp-2 w200 h24", "")
SrchBox.OnEvent("Change", OnSearchChanged)
; F3 = find next match (window-scoped hotkey registered after MainGui.Show)
; Row 2: Chunk spinner (left) then the reading-behavior checkboxes, then the voice picker.
LblChunk := MainGui.AddText("xm y" Row2Y " w42", "Chunk:")
EdChunk := MainGui.AddEdit("x+2 yp-2 w50 Number", Cfg.ChunkSize)
UdChunk := MainGui.AddUpDown("Range1-10", Cfg.ChunkSize)
EdChunk.OnEvent("Change", ChunkChanged)
CbxOverlap := MainGui.AddCheckbox("x+16 yp+3", "Overlap")
CbxOverlap.Value := Cfg.Overlap
CbxOverlap.OnEvent("Click", OverlapChanged)
CbxSentence := MainGui.AddCheckbox("x+14 yp", "Sentence pause")
CbxSentence.Value := Cfg.SentencePause
CbxSentence.OnEvent("Click", SentencePauseChanged)
CbxList := MainGui.AddCheckbox("x+14 yp", "List pauses")
CbxList.Value := Cfg.ListPauses
CbxList.OnEvent("Click", ListPausesChanged)
CbxSmart := MainGui.AddCheckbox("x+14 yp", "Smart pacing")
CbxSmart.Value := Cfg.SmartPacing
CbxSmart.OnEvent("Click", SmartPacingChanged)
CbxCenter := MainGui.AddCheckbox("x+14 yp", "Center scroll")
CbxCenter.Value := Cfg.CenterScroll
CbxCenter.OnEvent("Click", CenterScrollChanged)
CbxTTS := MainGui.AddCheckbox("x+14 yp", "Read Aloud")
CbxTTS.Value := Cfg.TTSMode
CbxTTS.OnEvent("Click", TTSModeChanged)
CbxTTS.Enabled := (Cfg.WPM <= TTSMaxWPM)
; Voice picker — "(Default voice)" first, then every installed SAPI voice found by
; EnumerateTTSVoices(). Selection is restored from Cfg.TTSVoiceId if it still matches an
; installed voice; otherwise falls back to "(Default voice)" (e.g. srSettings.ini was
; copied from a different machine with different voices installed).
DdlVoiceChoices := ["(Default voice)"]
For _, v in TTSVoiceList
DdlVoiceChoices.Push(v.Name)
DdlTTSVoice := MainGui.AddDropDownList("x+8 yp w170", DdlVoiceChoices)
DdlTTSVoice.OnEvent("Change", TTSVoiceChanged)
DdlTTSVoice.Value := 1
For i, v in TTSVoiceList {
If (v.Id = Cfg.TTSVoiceId) {
DdlTTSVoice.Value := i + 1
Break
}
}
; Row 3: WPM label + value (large) + time remaining on the left, slider filling the rest.
; WPM text is placed at Row3Y+6 so its centered text lines up with the taller slider.
LblWPMTitle := MainGui.AddText("xm y" (Row3Y + 6) " w50 h28 +0x200", "WPM:")
LblWPMTitle.SetFont("s12 Bold")
LblWPM := MainGui.AddText("x+2 yp w50 h28 +0x200", Cfg.WPM)
LblWPM.SetFont("s14 Bold")
LblTimeRemain := MainGui.AddText("x+6 yp w160 h28 +0x200", "")
LblTimeRemain.SetFont("s11")
SldWPM := MainGui.AddSlider("x+10 y" Row3Y " w300 h40 Range100-1200 TickInterval100 Page50 Line10 ToolTip", Cfg.WPM)
SldWPM.OnEvent("Change", WPMChanged)
; Reflect any loaded TTS setting in the controls that don't apply while it's active,
; and re-validate it against the WPM threshold in case srSettings.ini was hand-edited.
UpdateTTSControlStates()
GateTTSAvailability()
; ======================================================================================================================
; Show GUI and optionally auto-load last file
; ======================================================================================================================
MainGui.Show("w" Cfg.GuiW " h" Cfg.GuiH)
; Remove horizontal scrollbar from RichEdit — word-wrap makes it unnecessary,
; and it leaves an ugly empty trough when the window is resized narrow.
; WS_HSCROLL = 0x00100000
WinSetStyle("-0x100000", "ahk_id " RE.Hwnd)
; Populate the Recent Files menu now that the menu object exists
RebuildRecentMenu()
If (Cfg.LastFile != "" && FileExist(Cfg.LastFile)) {
savedIdx := RecentPositions.Has(StrLower(Cfg.LastFile)) ? RecentPositions[StrLower(Cfg.LastFile)] : 0
LoadTextFile(Cfg.LastFile, savedIdx)
}
; Poll for TextExtractor output every 1500 ms.
; CheckForTEOutput only acts when SpeedReader is the foreground window.
SetTimer(CheckForTEOutput, 1500)
; WM_LBUTTONDOWN (0x0201) — catch clicks on the colored swatch progress bars.
; The overlay Text labels are transparent so clicks pass through to the progress bar hwnd.
OnMessage(0x0201, OnSwatchClick)
; Hook WM_LBUTTONDBLCLK globally and filter by the RichEdit's HWND in the handler.
; This lets the user double-click a word to start reading from there.
OnMessage(0x0203, OnDoubleClick)
; Enable drag-and-drop of .txt files onto the window.
; DragAcceptFiles tells Windows to send WM_DROPFILES (0x0233) to this hwnd.
DllCall("shell32\DragAcceptFiles", "Ptr", MainGui.Hwnd, "Int", 1)
OnMessage(0x0233, OnDropFiles)
; Hook WM_SETFOCUS (0x0007) on the RichEdit: every time it gains focus it recreates
; the caret internally. We intercept this and immediately replace it with a zero-width
; caret, making it permanently invisible regardless of the blink cycle.
OnMessage(0x0007, OnRichEditFocus)
; Windows default: wheel-up = increase toward left (decrease WPM). We reverse it.
OnMessage(0x020A, OnMouseWheel)
; Left = slow down by WPMHotkeyStep
; Right = speed up by WPMHotkeyStep
; Down = toggle play/pause
; These suppress the native arrow-key behavior so pressing arrows means "speed control"
; — but ONLY when the focus is on one of the four controls where that makes sense: the
; reading pane, the Play/Restart buttons, and the WPM slider (the allowlist lives in
; ArrowHotkeysActive below). Everywhere else — the search box, the Size/Chunk edits, the
; Font/Voice dropdowns, the checkboxes, an open menu, or no control at all — the arrows
; keep their native meaning (caret movement, list navigation, menu navigation). This
; "hands off by default" design is what keeps the menu bar and text editing working
; without having to hunt down and exclude each control one at a time.
HotIf(ArrowHotkeysActive)
Hotkey("Left", AdjustWPM.Bind(-WPMHotkeyStep))
Hotkey("Right", AdjustWPM.Bind(+WPMHotkeyStep))
Hotkey("Down", (*) => TogglePlay())
HotIf()
; F3 / Ctrl+F are harmless (and useful) inside edit boxes, so they stay window-gated.
HotIfWinActive("ahk_id " MainGui.Hwnd)
Hotkey("F3", (*) => SearchFindNext())
Hotkey("^f", (*) => FocusSearchBox())
HotIfWinActive()
Return ; end of auto-execute section
; ======================================================================================================================
; ======================================================================================================================
; FUNCTIONS
; ======================================================================================================================
; ======================================================================================================================
; ----------------------------------------------------------------------------------------------------------------------
; HotIf context callback for the Left/Right/Down speed-control hotkeys.
; Allowlist (not denylist): the arrows only mean "speed control" when the SpeedReader
; window is active AND the focused control is one of the four where that makes sense —
; the reading pane (RE), the Play/Restart buttons, or the WPM slider. For anything else
; the arrows fall through to their native meaning, so the search box, Size/Chunk edits,
; Font/Voice dropdowns, and checkboxes all behave normally with no per-control exclusions.
;
; Menu bars are a special case: while a menu is open, the control that HAD focus (usually
; the reading pane) still reports as focused via ControlGetFocus, so the allowlist alone
; would keep hijacking the arrows and break menu navigation. IsMenuActive() detects menu
; mode explicitly so the arrows step aside whenever a menu is being navigated.
;
; A raw HWND (ControlGetFocus) is used rather than MainGui.FocusedCtrl because the reading
; pane is a RichEdit-class object rather than a standard Gui control.
; ----------------------------------------------------------------------------------------------------------------------
ArrowHotkeysActive(*) {
If (!WinActive("ahk_id " MainGui.Hwnd))
Return False
If (IsMenuActive()) ; a menu bar / popup menu is open — let arrows navigate it
Return False
fHwnd := 0
Try fHwnd := ControlGetFocus("ahk_id " MainGui.Hwnd)
Return (fHwnd = RE.Hwnd
|| fHwnd = BtnPlay.Hwnd
|| fHwnd = BtnRestart.Hwnd
|| fHwnd = SldWPM.Hwnd)
}
; ----------------------------------------------------------------------------------------------------------------------
; Returns True while a menu bar dropdown or popup menu owned by our GUI thread is displayed.
; GetGUIThreadInfo fills a GUITHREADINFO struct: the GUI_INMENUMODE flag is set the whole
; time the thread is navigating a menu (from Alt/click through moving between items), and
; hwndMenuOwner is non-zero while an actual menu window is shown. Either one means a menu
; is active and the arrow keys should be left alone. This is a plain API query (no message
; pumping), so it works even while the menu's own modal loop is running.
; Struct layout is derived from A_PtrSize, so it is correct on both 32- and 64-bit AHK.
; ----------------------------------------------------------------------------------------------------------------------
IsMenuActive() {
static GUI_INMENUMODE := 0x00000004
static size := 8 + (6 * A_PtrSize) + 16 ; cbSize+flags + 6 HWNDs + RECT
static ownerOffset := 8 + (3 * A_PtrSize) ; hwndMenuOwner sits after 3 HWNDs
gti := Buffer(size, 0)
NumPut("UInt", size, gti, 0) ; cbSize (required in-field)
tid := DllCall("GetWindowThreadProcessId", "Ptr", MainGui.Hwnd, "Ptr", 0, "UInt")
If (!DllCall("GetGUIThreadInfo", "UInt", tid, "Ptr", gti))
Return False
flags := NumGet(gti, 4, "UInt")
hwndMenuOwner := NumGet(gti, ownerOffset, "Ptr")
Return ((flags & GUI_INMENUMODE) || hwndMenuOwner) ? True : False
}
; ----------------------------------------------------------------------------------------------------------------------
; GUI resize handler — keep RichEdit filling the middle; reposition bottom rows; stretch slider
; ----------------------------------------------------------------------------------------------------------------------
MainGuiSize(GuiObj, MinMax, W, H) {
If (MinMax = -1)
Return
margin := 8
bottomBandH := 100 ; room for two rows + slider + a little padding
; Resize RichEdit
RE.GetPos(&rx, &ry)
newREH := H - ry - bottomBandH - margin
If (newREH < 50)
newREH := 50
RE.Move(, , W - 2*margin, newREH)
; Recompute row Ys based on new RichEdit bottom
RE.GetPos(&rx2, &ry2, &rw2, &rh2)
r1Y := ry2 + rh2 + 6
r2Y := r1Y + 34
r3Y := r2Y + 30
; Row 1 — color swatches (Text, Background, Highlight, Sentence) + overlay labels +
; Sentence-tint checkbox + search box. Fixed-width left-to-right; search gets the rest.
x := margin
SwTx.Move(x, r1Y)
SwTx.GetPos(, , &w1, &hh)
LblSwTx.Move(x, r1Y, w1, hh)
x += w1 + 4
SwBg.Move(x, r1Y)
SwBg.GetPos(, , &w2, &hh)
LblSwBg.Move(x, r1Y, w2, hh)
x += w2 + 4
SwHL.Move(x, r1Y)
SwHL.GetPos(, , &w3, &hh)
LblSwHL.Move(x, r1Y, w3, hh)
x += w3 + 4
SwSn.Move(x, r1Y)
SwSn.GetPos(, , &w4, &hh)
LblSwSn.Move(x, r1Y, w4, hh)
x += w4 + 12
CbxColorTint.Move(x, r1Y + 3)
CbxColorTint.GetPos(, , &wCT)
x += wCT + 16
LblSearch.GetPos(, , &wLS)
LblSearch.Move(x, r1Y + 5)
x += wLS + 2
; Search box stretches to right margin
srchW := W - x - margin
If (srchW < 60)
srchW := 60
SrchBox.Move(x, r1Y, srchW)
; Row 2 — Chunk spinner (fixed at left), then checkboxes, then the voice dropdown.
; The Edit and its UpDown are moved together so the spin buttons stay glued to the box.
cx := margin
LblChunk.Move(cx, r2Y + 4)
LblChunk.GetPos(, , &wLC)
cx += wLC + 2
EdChunk.Move(cx, r2Y + 1)
EdChunk.GetPos(, , &wEC, &hEC)
UdChunk.Move(cx + wEC, r2Y + 1, , hEC)
UdChunk.GetPos(, , &wUD)
cx += wEC + wUD + 18
cbxY := r2Y + 4
CbxOverlap.Move(cx, cbxY)
CbxOverlap.GetPos(, , &cbW)
cx += cbW + 14
CbxSentence.Move(cx, cbxY)
CbxSentence.GetPos(, , &cbW)
cx += cbW + 14
CbxList.Move(cx, cbxY)
CbxList.GetPos(, , &cbW)
cx += cbW + 14
CbxSmart.Move(cx, cbxY)
CbxSmart.GetPos(, , &cbW)
cx += cbW + 14
CbxCenter.Move(cx, cbxY)
CbxCenter.GetPos(, , &cbW)
cx += cbW + 14
CbxTTS.Move(cx, cbxY)
CbxTTS.GetPos(, , &cbW)
cx += cbW + 8
DdlTTSVoice.Move(cx, cbxY - 2)
; Row 3 — WPM label + value + time remaining (left), slider fills the rest.
LblWPMTitle.GetPos(, , &w1)
LblWPMTitle.Move(margin, r3Y + 6)
LblWPM.Move(margin + w1 + 2, r3Y + 6)
LblWPM.GetPos(, , &w2)
LblTimeRemain.Move(margin + w1 + 2 + w2 + 6, r3Y + 6)
LblTimeRemain.GetPos(, , &w3)
sldX := margin + w1 + 2 + w2 + 6 + w3 + 12
sldW := W - sldX - margin
If (sldW < 120)
sldW := 120
SldWPM.Move(sldX, r3Y, sldW)
}
; ----------------------------------------------------------------------------------------------------------------------
; Window close — save settings & exit
; ----------------------------------------------------------------------------------------------------------------------
GuiClosing(*) {
Critical
StopPlay(True)
MainGui.GetClientPos(, , &cw, &ch)
Cfg.GuiW := cw
Cfg.GuiH := ch
SaveSettings()
ExitApp()
}
; ----------------------------------------------------------------------------------------------------------------------
; File menu handler
; ----------------------------------------------------------------------------------------------------------------------
FileOpen(*) {
f := FileSelect(1, Cfg.LastFile, "Open text file", "Text Files (*.txt)")
If (f = "")
Return
savedIdx := RecentPositions.Has(StrLower(f)) ? RecentPositions[StrLower(f)] : 0
LoadTextFile(f, savedIdx)
}
; ----------------------------------------------------------------------------------------------------------------------
; Open srSettings.ini in the default editor (usually Notepad)
; ----------------------------------------------------------------------------------------------------------------------
OpenIniFile(*) {
If !FileExist(IniFile) {
MsgBox("srSettings.ini not found.`n`nIt will be created automatically when you change a setting.", AppName, 64)
Return
}
Run(IniFile)
}
; ----------------------------------------------------------------------------------------------------------------------
; Settings menu — "Force fallback pacer (Read Aloud)". This is the ForceFallbackPacer
; escape hatch: SAPI_Word ignores it live on every word event, so the toggle takes effect
; immediately (on the next spoken word) with no restart needed.
; ----------------------------------------------------------------------------------------------------------------------
ToggleForceFallbackPacer(*) {
Critical
Cfg.ForceFallbackPacer := !Cfg.ForceFallbackPacer
If (Cfg.ForceFallbackPacer)
SettingsMenu.Check(MenuLbl_FallbackPacer)
Else
SettingsMenu.Uncheck(MenuLbl_FallbackPacer)
SaveSettings()
}
; ----------------------------------------------------------------------------------------------------------------------
; Settings menu — "Break tint at colons & dashes" (ClauseTintBreaks). Re-annotates the
; already-tokenized words so the new segmentation applies without reloading the file, then
; repaints the current band if one is visible while paused/stopped.
; ----------------------------------------------------------------------------------------------------------------------
ToggleClauseTintBreaks(*) {
Critical
Cfg.ClauseTintBreaks := !Cfg.ClauseTintBreaks
If (Cfg.ClauseTintBreaks)
SettingsMenu.Check(MenuLbl_ClauseBreaks)
Else
SettingsMenu.Uncheck(MenuLbl_ClauseBreaks)
SaveSettings()
; Recompute the tint bands in place (safe — only rewrites each word's band range).
If (Words.Length > 0)
AnnotateSentenceBands()
; If a tint band is showing while not playing, repaint it now with the new bands.
; (While playing, the next tick repaints from the updated bands on its own.)
If (Cfg.ColorTint && !IsPlaying && PrevStart >= 0 && PrevEnd > PrevStart && CurIdx >= 1 && CurIdx <= Words.Length) {
savS := PrevStart, savE := PrevEnd
ClearHighlight()
HighlightRange(savS, savE, CurIdx, Words.Length)
}
}
; ----------------------------------------------------------------------------------------------------------------------
; Launch TextExtractor — portable exe pattern: TextExtractor.exe in same folder,
; falling back to running TextExtractor.ahk with the current AHK interpreter.
; TextExtractor is a companion, not part of SpeedReader, so we just launch and forget;
; SR learns about completed conversions via CheckForTEOutput polling.
; ----------------------------------------------------------------------------------------------------------------------
LaunchTextExtractor(*) {
teExe := A_ScriptDir "\TextExtractor.exe"
teAhk := A_ScriptDir "\TextExtractor.ahk"
If FileExist(teExe) {
; Already running? Just activate it instead of launching a second instance.
If WinExist("ahk_exe TextExtractor.exe")
WinActivate("ahk_exe TextExtractor.exe")
Else
Run('"' teExe '"')
} Else If FileExist(teAhk) {
Run('"' A_AhkPath '" "' teAhk '"')
} Else {
MsgBox("TextExtractor not found.`n`nExpected:`n" teExe "`n`nor:`n" teAhk
. "`n`nMake sure TextExtractor.exe (or TextExtractor.ahk) is in the same folder as SpeedReader.",
AppName, 48)
}
}
; ----------------------------------------------------------------------------------------------------------------------
; Poll for a completed TextExtractor conversion.
; Called by a 1500 ms repeating timer. Only acts when SpeedReader is the foreground
; window to avoid loading a file while the user is mid-read somewhere else.
; TextExtractor signals completion by writing TEConvertedAt + LastFile to srSettings.ini.
; New TE files have no saved position so they always load from word 1 (no resume prompt).
; ----------------------------------------------------------------------------------------------------------------------
CheckForTEOutput(*) {
global TELastStampSeen, Cfg, IniFile
If !WinActive("ahk_id " MainGui.Hwnd)
Return
stamp := IniRead(IniFile, "Session", "TEConvertedAt", "")
If (stamp = "" || stamp = TELastStampSeen)
Return
newPath := IniRead(IniFile, "Session", "LastFile", "")
; Strip any trailing ", wordIdx" that might be present from a previous SR write.
; Reuse the canonical parser so the format stays in sync with SaveSettings/LoadSettings.
dummyIdx := 0
newPath := ParseRecentEntry(newPath, &dummyIdx)
If (newPath = "" || !FileExist(newPath)) {
TELastStampSeen := stamp
Return
}
If (StrLower(newPath) = StrLower(Cfg.LastFile)) {
TELastStampSeen := stamp
Return
}
TELastStampSeen := stamp
; TE output is always fresh — load from word 1, no resume prompt
LoadTextFile(newPath, 0)
LblStatus.Text := "Loaded from TextExtractor: " FileBaseName(newPath)
}
; ----------------------------------------------------------------------------------------------------------------------
; Push a path to the top of RecentFiles, deduplicating and capping at RecentFilesMax.
; wordIdx is the saved reading position (0 = beginning / no position saved).
; If the file is already in the list, its position is updated in place before moving
; it to the top so we don't lose a position saved earlier in the session.
; ----------------------------------------------------------------------------------------------------------------------
PushRecentFile(path, wordIdx := 0) {
global RecentFiles, RecentFilesMax, RecentPositions
lp := StrLower(path)
; Update position map — but don't wipe a saved position with 0 when an entry
; already exists (e.g. LoadTextFile calls PushRecentFile(path, 0) before the
; resume prompt; we need to preserve the saved index until the user decides).
If (wordIdx > 0 || !RecentPositions.Has(lp))
RecentPositions[lp] := wordIdx
; Remove any existing occurrence (case-insensitive)
i := 1
While (i <= RecentFiles.Length) {
If (StrLower(RecentFiles[i]) = lp)
RecentFiles.RemoveAt(i)
Else
i++
}
RecentFiles.InsertAt(1, path)
While (RecentFiles.Length > RecentFilesMax)
RecentFiles.Pop()
}
; ----------------------------------------------------------------------------------------------------------------------
; Save the current reading position (CurIdx) for the loaded file so it can be
; offered as a resume point next time the file is opened.
; Called on pause and on window close.