Game-file footprint unchanged: still one line in main.lua. All 23 states now have descriptors; 5 patches applied. Final two descriptors: - keybindings_menu has two panels (commands and keys) with independent selection, so it reads currentlyActiveWindow to decide which one has focus. Announcing the wrong panel's row would be actively misleading, since the two lists are unrelated. - research is a 2D node graph rather than a list, and its nodes are drawn as icons with no text label, so the id is the only identifier available. What actually matters is researched / locked / cannot afford / available: without the affordability part the screen is unusable by ear, because pressing enter on an unaffordable node silently does nothing. status.lua adds two pull keys -- ' for health and active effects, , for the current action -- plus unprompted damage alerts. Damage is polled from actor.hp rather than hooked. ingame.checkInterruptions exists but only fires for interruptions during queued movement, so damage taken on your own turn never reaches it. Polling catches every source (combat, poison, fire, falling) with no game edit. Damage speaks immediately and interrupts; healing queues, since it is worth knowing but never urgent. patches/popupstack.lua wraps popupStack.add, the single entry point for loot, recipe, lore, map, quest, tutorial and bestiary popups. None of these go through console.print, so they were entirely silent before. speech.immediate now logs to the console like queued speech does. It bypasses the frame queue and so also bypassed the flush-time logger, which meant damage alerts fired correctly but were invisible in the testbed transcript. Prefixes are now: ! interrupt, * immediate, space queued. 26/26 testbed checks, history unit tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
4.8 KiB
Lua
190 lines
4.8 KiB
Lua
-- speech.lua — the mod's only route to text-to-speech.
|
|
--
|
|
-- Invariant: speech must NEVER throw. If the TTS backend is missing or broken,
|
|
-- every function here becomes a no-op and the game runs exactly as vanilla.
|
|
--
|
|
-- Utterances are queued within a frame rather than spoken immediately. A single
|
|
-- game turn routinely emits several console.print calls ("You hit the rat.",
|
|
-- "The rat dies.", "You feel cold."), and speaking each one with interrupt would
|
|
-- leave you hearing only the last. flush() joins the frame's queue into one
|
|
-- utterance, in order.
|
|
|
|
local speech = {}
|
|
|
|
local tts
|
|
local available = false
|
|
local failureReason
|
|
|
|
local queue = {}
|
|
local pendingInterrupt = false
|
|
|
|
-- Mirror every utterance to stdout. Worth keeping on by default during
|
|
-- development: it makes speech verifiable from the lovec console log rather than
|
|
-- only by ear, which matters when diagnosing whether a hook fired at all versus
|
|
-- fired with empty text.
|
|
speech.logToConsole = true
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Initialisation
|
|
--------------------------------------------------------------------------------
|
|
|
|
function speech.initialize()
|
|
-- tolk.lua calls Tolk_Load() at require time and raises if the DLL is absent,
|
|
-- so this pcall is load-bearing, not defensive garnish.
|
|
local ok, result = pcall(require, "accessibility.vendor.love2talk.Love2talk")
|
|
|
|
if not ok then
|
|
available = false
|
|
failureReason = tostring(result)
|
|
print("[accessibility] TTS unavailable, running silent: " .. failureReason)
|
|
return false
|
|
end
|
|
|
|
tts = result
|
|
available = true
|
|
print("[accessibility] TTS initialised")
|
|
return true
|
|
end
|
|
|
|
function speech.isAvailable()
|
|
return available
|
|
end
|
|
|
|
function speech.getFailureReason()
|
|
return failureReason
|
|
end
|
|
|
|
-- Whether Tolk found a real speech channel (running screen reader, or SAPI
|
|
-- fallback). isAvailable() only means the library loaded.
|
|
function speech.hasSpeech()
|
|
if not available or not tts.hasSpeech then
|
|
return false
|
|
end
|
|
|
|
local ok, result = pcall(tts.hasSpeech)
|
|
return ok and result or false
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Text normalisation
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Screen readers handle a single clean line far better than raw game strings,
|
|
-- which arrive with newlines (the game wraps its own text) and padding.
|
|
local function normalise(text)
|
|
if text == nil then
|
|
return nil
|
|
end
|
|
|
|
if type(text) ~= "string" then
|
|
text = tostring(text)
|
|
end
|
|
|
|
text = text:gsub("[\r\n]+", ". ")
|
|
text = text:gsub("%s+", " ")
|
|
text = text:gsub("^%s+", ""):gsub("%s+$", "")
|
|
|
|
if text == "" then
|
|
return nil
|
|
end
|
|
|
|
return text
|
|
end
|
|
|
|
speech.normalise = normalise
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Speaking
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Queue text to be spoken at the end of this frame.
|
|
-- interrupt=true discards anything already queued this frame AND cuts off whatever
|
|
-- the screen reader is currently saying. Use it for keypress-driven output, where
|
|
-- the user expects an immediate answer to the key they just pressed.
|
|
function speech.say(text, interrupt)
|
|
text = normalise(text)
|
|
if not text then
|
|
return
|
|
end
|
|
|
|
if interrupt then
|
|
queue = {text}
|
|
pendingInterrupt = true
|
|
else
|
|
queue[#queue + 1] = text
|
|
end
|
|
end
|
|
|
|
-- Convenience: the common case for hotkeys.
|
|
function speech.interrupt(text)
|
|
speech.say(text, true)
|
|
end
|
|
|
|
-- Speak immediately, bypassing the queue. Reserve this for urgent alerts (taking
|
|
-- damage) where waiting for end-of-frame is wrong.
|
|
function speech.immediate(text, interrupt)
|
|
text = normalise(text)
|
|
if not text then
|
|
return
|
|
end
|
|
|
|
-- Log before the availability check, so immediate alerts are visible in the
|
|
-- testbed transcript exactly like queued speech.
|
|
if speech.logToConsole then
|
|
print("[speech]* " .. text)
|
|
end
|
|
|
|
if not available then
|
|
return
|
|
end
|
|
|
|
pcall(tts.say, text, interrupt ~= false)
|
|
end
|
|
|
|
-- Called once per frame from init.lua's update hook.
|
|
function speech.flush()
|
|
if #queue == 0 then
|
|
return
|
|
end
|
|
|
|
local text = table.concat(queue, ". ")
|
|
local shouldInterrupt = pendingInterrupt
|
|
|
|
queue = {}
|
|
pendingInterrupt = false
|
|
|
|
if speech.logToConsole then
|
|
print("[speech]" .. (shouldInterrupt and "!" or " ") .. " " .. text)
|
|
end
|
|
|
|
if not available then
|
|
return
|
|
end
|
|
|
|
pcall(tts.say, text, shouldInterrupt)
|
|
end
|
|
|
|
function speech.silence()
|
|
queue = {}
|
|
pendingInterrupt = false
|
|
|
|
if not available or not tts.silence then
|
|
return
|
|
end
|
|
|
|
pcall(tts.silence)
|
|
end
|
|
|
|
-- Unreliable under screen readers (they don't report speaking state); only
|
|
-- meaningful with the SAPI fallback. Do not use for timing.
|
|
function speech.isSpeaking()
|
|
if not available then
|
|
return false
|
|
end
|
|
|
|
local ok, result = pcall(tts.isSpeaking)
|
|
return ok and result or false
|
|
end
|
|
|
|
return speech
|