Implement vocal review mode
John Smith

John Smith commited on 2023-98 00:07:59
Showing 2 changed files, with 120 additions and 0 deletions.


With the "v" option, Memoire can run interactive reviews thru
text-to-speech, now requiring only text input and speakers rather than
a screen. The user can customise the text-to-speech with the EspeakFlags
config option, and can have the cards processed more precisely than
multiclozes by writing a prspeak.lua in their configuration directory.
... ...
@@ -175,6 +175,27 @@ local function mismatched(s)
175 175
     return ("}" .. s):find("}[^{]+}") or (s .. "{"):find("{[^}]+{")
176 176
 end
177 177
 
178
+--[[
179
+    presents `text` thru text-to-speech,
180
+    with language/voice code `lang` (`false` or `nil` for default)
181
+    `text` and `lang` may also both be parallel tables
182
+    implementation may be platform-specific
183
+]]
184
+local function speak(text, conf, lang)
185
+    if type(text) == "string" and (type(lang) == "string" or type(lang) == "nil") then
186
+        text = {text}
187
+        lang = {lang}
188
+    end
189
+    for i, t in ipairs(text) do
190
+        os.execute(string.format(
191
+            "espeak %s %s %q",
192
+            conf:get("EspeakFlags") or "",
193
+            lang[i] and ("-v " .. lang[i]) or "",
194
+            t
195
+        ))
196
+    end
197
+end
198
+
178 199
 local actions = {
179 200
     a = {
180 201
         desc = "(a)dd card (from stdin)",
... ...
@@ -482,6 +503,89 @@ local actions = {
482 503
             return deck
483 504
         end,
484 505
     },
506
+
507
+    v = {
508
+        desc = "(v)ocal review session",
509
+        nd = true,
510
+        fn = function(config, deck, deckfname)
511
+            if not isatty() then
512
+                io.write("Review should be done in the terminal\n")
513
+                return nil
514
+            end
515
+            -- collect list of index-pairs for prompts to be reviewed this session
516
+            local ctrt = review.list(deck, config, function(pr) return not pr:spt():find("IMG%[") end)
517
+            speak(string.format(
518
+                "%d prompts to review; how many in this session? ", #ctrt
519
+            ), config)
520
+            local climt = io.read("l")
521
+            local climn = tonumber(climt)
522
+            if not climn then
523
+                speak("Input not a number; aborting", conf)
524
+                return nil
525
+            end
526
+            climn = math.min(climn, #ctrt)
527
+            local lrc = 0
528
+            local editqueue = {}
529
+            local start = os.time()
530
+            -- review prompts in this session's set
531
+            for ind, ip in ipairs(ctrt) do
532
+                if ind > climn then
533
+                    break
534
+                end
535
+                local pr = deck[ip[1]][ip[2]]
536
+                -- remove braces for display
537
+                local pst = pr.text:gsub("%{([^{}]+)%}", "%1")
538
+                local st = os.time()
539
+                io.write(pst .. "\n" .. ind .. "/" .. climn .. "\n")
540
+                local qt, ql, at, al = prspeak(pr)
541
+                local heard = false
542
+                while not heard do
543
+                    speak(qt, config, ql)
544
+                    if io.read("l") ~= "r" then heard = true end
545
+                end
546
+                speak(at, config, al)
547
+                speak("hard, good, easy?", config)
548
+                local resp = io.read("l")
549
+                local sc = succfromstr(resp)
550
+                if sc == -1 then
551
+                    table.insert(editqueue, ip)
552
+                else
553
+                    if sc == 0 then
554
+                        lrc = lrc + 1
555
+                    end
556
+                    review.update(deck[ip[1]][ip[2]], st, sc, config)
557
+                end
558
+                clear()
559
+                if resp:sub(#resp):lower() == "q" then
560
+                    climn = ind
561
+                    break
562
+                end
563
+            end
564
+            if #ctrt > 0 then
565
+                local endt = os.time()
566
+                io.write(string.format(
567
+                    "Reviewed %d prompts in %d seconds\n",
568
+                    math.min(#ctrt, climn),
569
+                    endt - start
570
+                ))
571
+                local s
572
+                if lrc == 1 then s = "" else s = "s" end
573
+                io.write(string.format(
574
+                    "(%d lapse%s, %f seconds per card)\n",
575
+                    lrc, s, (endt - start) / math.min(#ctrt, climn)
576
+                ))
577
+            end
578
+            if #editqueue > 0 then
579
+                io.write(string.format("The following cards are marked for correction:\n"))
580
+                for _, ip in ipairs(editqueue) do
581
+                    io.write(string.format("%d:%d %s\n", ip[1], ip[2], deck[ip[1]][ip[2]]))
582
+                end
583
+            end
584
+            if climn > 0 then
585
+                return deck
586
+            end
587
+        end,
588
+    },
485 589
 }
486 590
 
487 591
 return actions
... ...
@@ -73,6 +73,22 @@ else
73 73
     deckfname = config.DeckFile[1]
74 74
 end
75 75
 
76
+--[[
77
+    returns four tables:
78
+    question-text, question-lang, answer-text, answer-lang
79
+]]
80
+defaultprspeak = function(pr)
81
+    return {(pr.text:gsub("%{([^{}]+)%}", "%1"):gsub("%%A", "WHAT"))}, {false}, {"ANSWER: " .. pr.ans}, {false}
82
+end
83
+prspeak = defaultprspeak
84
+do
85
+local fh = io.open(confdir .. "prspeak.lua")
86
+if fh then
87
+    fh:close()
88
+    prspeak = dofile(confdir .. "prspeak.lua")
89
+end
90
+end
91
+
76 92
 deckfname = os.getenv("HOME") .. "/" .. deckfname
77 93
 
78 94
 math.randomseed(os.time())
79 95